소스 검색

Type session not-found errors (#25818)

Kit Langton 4 달 전
부모
커밋
8555de8189
22개의 변경된 파일625개의 추가작업 그리고 134개의 파일을 삭제
  1. 329 0
      packages/opencode/specs/effect/errors.md
  2. 4 3
      packages/opencode/src/cli/cmd/session.ts
  3. 14 3
      packages/opencode/src/control-plane/workspace.ts
  4. 2 0
      packages/opencode/src/server/routes/instance/httpapi/AGENTS.md
  5. 18 0
      packages/opencode/src/server/routes/instance/httpapi/errors.ts
  6. 5 4
      packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts
  7. 10 8
      packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
  8. 2 1
      packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts
  9. 4 3
      packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts
  10. 9 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts
  11. 49 56
      packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
  12. 4 9
      packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts
  13. 5 1
      packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts
  14. 4 4
      packages/opencode/src/session/prompt.ts
  15. 7 7
      packages/opencode/src/session/revert.ts
  16. 8 7
      packages/opencode/src/session/session.ts
  17. 2 2
      packages/opencode/test/control-plane/workspace.test.ts
  18. 11 12
      packages/opencode/test/server/httpapi-parity.test.ts
  19. 15 3
      packages/opencode/test/server/httpapi-pty.test.ts
  20. 62 7
      packages/opencode/test/server/httpapi-sdk.test.ts
  21. 47 3
      packages/opencode/test/server/httpapi-session.test.ts
  22. 14 1
      packages/opencode/test/server/httpapi-tui.test.ts

+ 329 - 0
packages/opencode/specs/effect/errors.md

@@ -0,0 +1,329 @@
+# Typed error migration
+
+Plan for moving `packages/opencode` from temporary defect/`NamedError`
+compatibility toward typed Effect service errors and explicit HTTP error
+contracts.
+
+## Goal
+
+- Expected service failures live on the Effect error channel.
+- Service interfaces expose those failures in their return types.
+- Domain errors are authored with Effect Schema so they are reusable by services,
+  tests, HTTP routes, tools, and OpenAPI generation.
+- HTTP status codes and wire compatibility are handled at the HTTP boundary, not
+  inside service modules.
+- `Effect.die`, `throw`, `catchDefect`, and global cause inspection are reserved
+  for defects, compatibility bridges, or final fallback behavior.
+
+## Current State
+
+- Many migrated services use Effect internally, but expected failures are still a
+  mix of `NamedError.create(...)`, `namedSchemaError(...)`, `class extends Error`,
+  `throw`, and `Effect.die(...)`.
+- Some services already use `Schema.TaggedErrorClass`, for example `Account`,
+  `Auth`, `Permission`, `Question`, `Installation`, and parts of
+  `Workspace`.
+- Legacy Hono error handling recognizes `NamedError`, `Session.BusyError`, and a
+  few name-based cases, then emits the legacy `{ name, data }` JSON body.
+- Effect `HttpApi` only knows how to encode errors that are declared on the
+  endpoint, group, or middleware. Undeclared expected errors become defects and
+  eventually fall through to generic HTTP handling.
+- The temporary HttpApi error middleware catches defect-wrapped legacy errors to
+  preserve runtime behavior, but it is intentionally a bridge rather than the
+  final model.
+
+## End State
+
+Service modules own domain failures.
+
+```ts
+export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
+  sessionID: SessionID,
+  message: Schema.String,
+}) {}
+
+export type Error = Storage.Error | SessionBusyError
+
+export interface Interface {
+  readonly get: (id: SessionID) => Effect.Effect<Info, Error>
+}
+```
+
+HTTP modules own transport mapping.
+
+```ts
+const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
+  return yield* session
+    .get(ctx.params.sessionID)
+    .pipe(
+      Effect.catchTag("StorageNotFoundError", () => new SessionNotFoundHttpError({ sessionID: ctx.params.sessionID })),
+    )
+})
+```
+
+HTTP-visible error schemas carry their own response status through Effect
+HttpApi's `httpApiStatus` annotation. Prefer `HttpApiSchema.status(...)`, or the
+equivalent declaration annotation, instead of maintaining a parallel status map.
+
+```ts
+export class SessionNotFoundHttpError extends Schema.TaggedErrorClass<SessionNotFoundHttpError>()(
+  "SessionNotFoundHttpError",
+  {
+    sessionID: SessionID,
+    message: Schema.String,
+  },
+  { httpApiStatus: 404 },
+) {}
+```
+
+Endpoint definitions still declare which HTTP-visible error schemas can be
+emitted. The status annotation is only used if the error is part of the endpoint,
+group, or middleware error schema and the handler fails with that error on the
+typed error channel.
+
+```ts
+HttpApiEndpoint.get("get", SessionPaths.get, {
+  success: Session.Info,
+  error: [SessionNotFoundHttpError, SessionBusyHttpError],
+})
+```
+
+The service error and HTTP error may be the same class when the wire shape is a
+deliberate public contract. They should be different classes when the service
+error contains internals, low-level causes, retry hints, or anything that should
+not be exposed to API clients.
+
+## Rules
+
+- Use `Schema.TaggedErrorClass` for new expected domain errors.
+- Include `cause: Schema.optional(Schema.Defect)` only when preserving an
+  underlying unknown failure is useful for logs or callers.
+- Export a domain-level error union from each service module, for example
+  `export type Error = NotFoundError | BusyError | Storage.Error`.
+- Put expected errors in service method signatures, for example
+  `Effect.Effect<Result, Service.Error, R>`.
+- Use `yield* new DomainError(...)` for direct early failures inside
+  `Effect.gen` / `Effect.fn`.
+- Use `Effect.try({ try, catch })`, `Effect.mapError`, or `Effect.catchTag` to
+  convert external exceptions into domain errors.
+- Use `HttpApiSchema.status(...)` or `{ httpApiStatus: code }` on HTTP-visible
+  error schemas so Effect `HttpApiBuilder` and OpenAPI generation get the status
+  from the schema itself.
+- Do not use `Effect.die(...)` for user, IO, validation, missing-resource, auth,
+  provider, worktree, or busy-state failures.
+- Do not use `catchDefect` to recover expected domain errors. If recovery is
+  needed, the upstream effect should fail with a typed error instead.
+- Do not make service modules import `HttpApiError`, `HttpServerResponse`, HTTP
+  status codes, or route-specific error schemas.
+- Keep raw `HttpRouter` routes free to use `HttpServerRespondable` when that is
+  the right transport abstraction, but prefer declared `HttpApi` errors for
+  normal JSON API endpoints.
+
+## HTTP Boundary Shape
+
+Create an HttpApi-local error module, likely
+`src/server/routes/instance/httpapi/errors.ts`.
+
+That module should provide:
+
+- Legacy-compatible public schemas for `{ name, data }` error bodies that must
+  remain SDK-compatible during the Hono migration.
+- Small constructors or mapping helpers for common API errors such as not found,
+  bad request, conflict, and unknown internal errors.
+- Route-group-specific adapters only when they encode domain-specific public
+  data.
+- A single place to document which public error shape is legacy-compatible and
+  which shape is new Effect-native API surface.
+
+Avoid one giant `unknown -> status` mapper. Prefer small, explicit mappers close
+to the handler or route group.
+
+```ts
+const mapSessionError = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
+  effect.pipe(
+    Effect.catchTag("StorageNotFoundError", (error) => new SessionNotFoundHttpError({ message: error.message })),
+    Effect.catchTag("SessionBusyError", (error) => new SessionBusyHttpError({ message: error.message })),
+  )
+```
+
+Use built-in `HttpApiError.BadRequest`, `HttpApiError.NotFound`, and related
+types only when their generated response body and SDK surface are intentionally
+acceptable. Use a custom schema-backed error when clients need the legacy
+`{ name, data }` body or a domain-specific error payload.
+
+## Migration Phases
+
+### 1. Stabilize The Bridge
+
+Keep the temporary HttpApi error middleware only as a compatibility bridge while
+typed errors are introduced.
+
+- Add tests that prove the bridge catches legacy `NamedError` defects.
+- Add tests that prove declared HttpApi errors still use the declared endpoint
+  contract.
+- Stop returning stack traces in unknown HTTP `500` responses; log the full
+  `Cause.pretty(cause)` server-side instead.
+- Add a comment or TODO that names this plan and states the bridge must shrink
+  as route groups migrate.
+
+### 2. Define The Shared HTTP Error Helpers
+
+Add the `httpapi/errors.ts` module before converting route groups.
+
+- Define a legacy `{ name, data }` body helper for SDK-compatible errors.
+- Define `UnknownError` for generic internal failures with a safe public message.
+- Define `BadRequestError` and `NotFoundError` equivalents only if the actual
+  wire body must match the legacy Hono SDK surface.
+- Put the HTTP status on the public schema with `HttpApiSchema.status(...)` or
+  `{ httpApiStatus: code }`; do not keep a separate name-to-status table.
+- Keep conversion helpers pure and small. They should not inspect `Cause` or
+  accept `unknown` unless they are final fallback helpers.
+
+### 3. Convert One Vertical Slice
+
+Start with session read routes because they already have local `mapNotFound`
+logic and are heavily covered by existing HttpApi tests.
+
+- Convert `Session.BusyError` from a plain `Error` to a typed service error, or
+  add a typed wrapper while preserving the old constructor until callers are
+  migrated.
+- Replace `catchDefect` in `httpapi/handlers/session.ts` with typed error
+  mapping.
+- Add endpoint error schemas for the affected session endpoints.
+- Prove behavior with focused tests in `test/server/httpapi-session.test.ts`.
+- Remove the migrated cases from the global compatibility middleware.
+
+### 4. Convert Legacy NamedError Domains
+
+Move legacy `NamedError.create(...)` services to Effect Schema-backed errors in
+small domain PRs.
+
+Priority order:
+
+1. `storage/storage.ts` and `storage/db.ts` not-found errors.
+2. `worktree/index.ts` `Worktree*` errors.
+3. `provider/auth.ts` validation failures and `provider/provider.ts` model-not-found errors.
+4. `mcp/index.ts`, `skill/index.ts`, `lsp/client.ts`, and `ide/index.ts` service errors.
+5. Config and CLI-only errors after HTTP-facing domains are stable.
+
+For each domain:
+
+- Replace `NamedError.create(...)` with `Schema.TaggedErrorClass` when the error
+  is primarily a service error.
+- Keep or add a separate HTTP error schema when the legacy `{ name, data }` wire
+  shape must remain stable.
+- Update service interface return types to include the new error union.
+- Replace `throw new X(...)` inside `Effect.fn` with `yield* new X(...)`.
+- Replace async exceptions with `Effect.try({ catch })` or explicit `mapError`.
+- Add service-level tests that assert the error tag and data, not just the HTTP
+  status.
+
+### 5. Declare HttpApi Errors Group By Group
+
+For each HttpApi group:
+
+- Inventory every service call and the typed errors it can return.
+- Add only the public error schemas that endpoint can actually emit.
+- Map service errors to HTTP errors in the handler file.
+- Keep built-in `HttpApiError` only for generic request/validation failures where
+  the generated contract is accepted.
+- Update `httpapi/public.ts` compatibility transforms only when the generated
+  spec cannot represent the desired source shape directly.
+- Regenerate the SDK after OpenAPI-visible changes and verify the diff is
+  intentional.
+
+Suggested route order:
+
+1. `session` not-found and busy-state reads.
+2. `experimental` worktree mutations.
+3. `provider` auth and model selection errors.
+4. `mcp` OAuth and connection errors.
+5. Remaining route groups as Hono deletion work progresses.
+
+### 6. Remove Defect Recovery
+
+After enough route groups declare their expected errors:
+
+- Delete `catchDefect` recovery for domain errors.
+- Delete name-prefix checks such as `error.name.startsWith("Worktree")` from
+  HTTP middleware.
+- Delete `NamedError` branches from the Effect HttpApi compatibility middleware
+  once no Effect route depends on them.
+- Leave one final unknown-defect fallback that logs server-side and returns a
+  safe generic `500` body.
+
+## Inventory Checklist
+
+Use this checklist when touching a service or route group.
+
+- [ ] Does the service interface expose every expected failure in the Effect
+      error type?
+- [ ] Are user-caused, provider-caused, IO, auth, missing-resource, and busy-state
+      failures modeled as typed errors instead of defects?
+- [ ] Does the service avoid importing HTTP status, `HttpApiError`, or response
+      classes?
+- [ ] Does the handler map each service error into a declared endpoint error?
+- [ ] Does the endpoint `error` field include every public error the handler can
+      emit?
+- [ ] Does OpenAPI/SDK output either stay byte-identical or have an explicitly
+      reviewed diff?
+- [ ] Do tests cover both service-level error typing and HTTP-level status/body?
+- [ ] Did the PR remove any now-unneeded case from the temporary compatibility
+      middleware?
+
+## Testing Requirements
+
+For service conversions:
+
+- Test the service method directly with `testEffect(...)`.
+- Assert on `_tag` or class identity and the structured fields.
+- Avoid testing by string-matching `Cause.pretty(...)`.
+
+For HttpApi conversions:
+
+- Add or update the focused `test/server/httpapi-*.test.ts` file.
+- Assert status code, content type, and exact JSON body for declared public
+  errors.
+- Add a regression test that the temporary middleware is no longer needed for the
+  migrated route.
+- Keep bridge/parity tests aligned with legacy Hono behavior until Hono is
+  deleted or the SDK contract intentionally changes.
+
+## Verification Commands
+
+Run from `packages/opencode` unless noted otherwise.
+
+```bash
+bun run prettier --write <changed files>
+bunx oxlint <changed files>
+bun typecheck
+bun run test -- test/server/httpapi-session.test.ts
+```
+
+Run SDK generation from the repo root when schemas or OpenAPI-visible errors
+change.
+
+```bash
+./packages/sdk/js/script/build.ts
+```
+
+## Open Questions
+
+- Should legacy V1 routes keep `{ name, data }` forever while V2 routes expose a
+  more Effect-native tagged error body?
+- Should storage not-found remain generic, or should callers map it to
+  domain-specific not-found errors before crossing service boundaries?
+- Should `namedSchemaError(...)` stay as a long-term public-wire helper, or only
+  as a migration bridge for old `NamedError` contracts?
+- Which SDK version boundary lets us stop remapping built-in Effect HttpApi error
+  schemas in `httpapi/public.ts`?
+
+## Success Criteria
+
+- New service code no longer uses `die` for expected failures.
+- A route reviewer can read an endpoint definition and see every public error it
+  can return.
+- The temporary HttpApi error middleware shrinks over time instead of gaining new
+  name-based cases.
+- Service tests prove domain error types without going through HTTP.
+- HTTP tests prove status/body contracts without relying on defect recovery.

+ 4 - 3
packages/opencode/src/cli/cmd/session.ts

@@ -9,6 +9,7 @@ import { Locale } from "@/util/locale"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Filesystem } from "@/util/filesystem"
 import { Filesystem } from "@/util/filesystem"
 import { Process } from "@/util/process"
 import { Process } from "@/util/process"
+import { NotFoundError } from "@/storage/storage"
 import { EOL } from "os"
 import { EOL } from "os"
 import path from "path"
 import path from "path"
 import { which } from "../../util/which"
 import { which } from "../../util/which"
@@ -59,9 +60,9 @@ export const SessionDeleteCommand = effectCmd({
   handler: Effect.fn("Cli.session.delete")(function* (args) {
   handler: Effect.fn("Cli.session.delete")(function* (args) {
     const svc = yield* Session.Service
     const svc = yield* Session.Service
     const sessionID = SessionID.make(args.sessionID)
     const sessionID = SessionID.make(args.sessionID)
-    // Match legacy try/catch — Session.get surfaces NotFoundError as a defect.
-    yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`)))
-    yield* svc.remove(sessionID)
+    yield* svc.remove(sessionID).pipe(
+      Effect.catchIf(NotFoundError.isInstance, () => fail(`Session not found: ${args.sessionID}`)),
+    )
     UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
     UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
   }),
   }),
 })
 })

+ 14 - 3
packages/opencode/src/control-plane/workspace.ts

@@ -24,11 +24,12 @@ import { Session } from "@/session/session"
 import { SessionPrompt } from "@/session/prompt"
 import { SessionPrompt } from "@/session/prompt"
 import { SessionTable } from "@/session/session.sql"
 import { SessionTable } from "@/session/session.sql"
 import { SessionID } from "@/session/schema"
 import { SessionID } from "@/session/schema"
+import { NotFoundError } from "@/storage/storage"
 import { errorData } from "@/util/error"
 import { errorData } from "@/util/error"
 import { waitEvent } from "./util"
 import { waitEvent } from "./util"
 import { WorkspaceContext } from "./workspace-context"
 import { WorkspaceContext } from "./workspace-context"
 import { EffectBridge } from "@/effect/bridge"
 import { EffectBridge } from "@/effect/bridge"
-import { NonNegativeInt, withStatics } from "@/util/schema"
+import { withStatics } from "@/util/schema"
 import { zod as effectZod, zodObject } from "@/util/effect-zod"
 import { zod as effectZod, zodObject } from "@/util/effect-zod"
 
 
 export const Info = WorkspaceInfoSchema
 export const Info = WorkspaceInfoSchema
@@ -739,9 +740,19 @@ export const layer = Layer.effect(
 
 
     const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) {
     const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) {
       const sessions = yield* db((db) =>
       const sessions = yield* db((db) =>
-        db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
+        db
+          .select({ id: SessionTable.id, parentID: SessionTable.parent_id })
+          .from(SessionTable)
+          .where(eq(SessionTable.workspace_id, id))
+          .all(),
+      )
+      const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id))
+      yield* Effect.forEach(
+        sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)),
+        (sessionInfo) =>
+          session.remove(sessionInfo.id).pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.void)),
+        { discard: true },
       )
       )
-      yield* Effect.forEach(sessions, (sessionInfo) => session.remove(sessionInfo.id), { discard: true })
 
 
       const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
       const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
       if (!row) return
       if (!row) return

+ 2 - 0
packages/opencode/src/server/routes/instance/httpapi/AGENTS.md

@@ -32,4 +32,6 @@ Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally re
 
 
 Use `Effect.provideService(...)` in middleware only for request-derived context, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. Do not use it to smuggle stable services through request effects when they can be yielded at layer construction.
 Use `Effect.provideService(...)` in middleware only for request-derived context, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. Do not use it to smuggle stable services through request effects when they can be yielded at layer construction.
 
 
+Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary.
+
 When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.
 When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.

+ 18 - 0
packages/opencode/src/server/routes/instance/httpapi/errors.ts

@@ -0,0 +1,18 @@
+import { Schema } from "effect"
+
+export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFoundError")(
+  {
+    name: Schema.Literal("NotFoundError"),
+    data: Schema.Struct({
+      message: Schema.String,
+    }),
+  },
+  { httpApiStatus: 404 },
+) {}
+
+export function notFound(message: string) {
+  return new ApiNotFoundError({
+    name: "NotFoundError",
+    data: { message },
+  })
+}

+ 5 - 4
packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts

@@ -6,6 +6,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e
 import { Authorization } from "../middleware/authorization"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
 import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
 import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
+import { ApiNotFoundError } from "../errors"
 import { described } from "./metadata"
 import { described } from "./metadata"
 
 
 const root = "/pty"
 const root = "/pty"
@@ -64,7 +65,7 @@ export const PtyApi = HttpApi.make("pty")
         HttpApiEndpoint.get("get", PtyPaths.get, {
         HttpApiEndpoint.get("get", PtyPaths.get, {
           params: { ptyID: PtyID },
           params: { ptyID: PtyID },
           success: described(Pty.Info, "Session info"),
           success: described(Pty.Info, "Session info"),
-          error: HttpApiError.NotFound,
+          error: ApiNotFoundError,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "pty.get",
             identifier: "pty.get",
@@ -76,7 +77,7 @@ export const PtyApi = HttpApi.make("pty")
           params: { ptyID: PtyID },
           params: { ptyID: PtyID },
           payload: Pty.UpdateInput,
           payload: Pty.UpdateInput,
           success: described(Pty.Info, "Updated session"),
           success: described(Pty.Info, "Updated session"),
-          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
+          error: [HttpApiError.BadRequest, ApiNotFoundError],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "pty.update",
             identifier: "pty.update",
@@ -87,7 +88,7 @@ export const PtyApi = HttpApi.make("pty")
         HttpApiEndpoint.delete("remove", PtyPaths.remove, {
         HttpApiEndpoint.delete("remove", PtyPaths.remove, {
           params: { ptyID: PtyID },
           params: { ptyID: PtyID },
           success: described(Schema.Boolean, "Session removed"),
           success: described(Schema.Boolean, "Session removed"),
-          error: HttpApiError.NotFound,
+          error: ApiNotFoundError,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "pty.remove",
             identifier: "pty.remove",
@@ -98,7 +99,7 @@ export const PtyApi = HttpApi.make("pty")
         HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
         HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
           params: { ptyID: PtyID },
           params: { ptyID: PtyID },
           success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
           success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
-          error: [HttpApiError.Forbidden, HttpApiError.NotFound],
+          error: [HttpApiError.Forbidden, ApiNotFoundError],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "pty.connectToken",
             identifier: "pty.connectToken",

+ 10 - 8
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts

@@ -15,6 +15,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op
 import { Authorization } from "../middleware/authorization"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
 import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
 import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
+import { ApiNotFoundError } from "../errors"
 import { described } from "./metadata"
 import { described } from "./metadata"
 
 
 const root = "/session"
 const root = "/session"
@@ -123,7 +124,7 @@ export const SessionApi = HttpApi.make("session")
         HttpApiEndpoint.get("get", SessionPaths.get, {
         HttpApiEndpoint.get("get", SessionPaths.get, {
           params: { sessionID: SessionID },
           params: { sessionID: SessionID },
           success: described(Session.Info, "Get session"),
           success: described(Session.Info, "Get session"),
-          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
+          error: [HttpApiError.BadRequest, ApiNotFoundError],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "session.get",
             identifier: "session.get",
@@ -168,7 +169,7 @@ export const SessionApi = HttpApi.make("session")
           params: { sessionID: SessionID },
           params: { sessionID: SessionID },
           query: MessagesQuery,
           query: MessagesQuery,
           success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
           success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
-          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
+          error: [HttpApiError.BadRequest, ApiNotFoundError],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "session.messages",
             identifier: "session.messages",
@@ -179,7 +180,7 @@ export const SessionApi = HttpApi.make("session")
         HttpApiEndpoint.get("message", SessionPaths.message, {
         HttpApiEndpoint.get("message", SessionPaths.message, {
           params: { sessionID: SessionID, messageID: MessageID },
           params: { sessionID: SessionID, messageID: MessageID },
           success: described(MessageV2.WithParts, "Message"),
           success: described(MessageV2.WithParts, "Message"),
-          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
+          error: [HttpApiError.BadRequest, ApiNotFoundError],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "session.message",
             identifier: "session.message",
@@ -201,7 +202,7 @@ export const SessionApi = HttpApi.make("session")
         HttpApiEndpoint.delete("remove", SessionPaths.remove, {
         HttpApiEndpoint.delete("remove", SessionPaths.remove, {
           params: { sessionID: SessionID },
           params: { sessionID: SessionID },
           success: described(Schema.Boolean, "Successfully deleted session"),
           success: described(Schema.Boolean, "Successfully deleted session"),
-          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
+          error: [HttpApiError.BadRequest, ApiNotFoundError],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "session.delete",
             identifier: "session.delete",
@@ -213,7 +214,7 @@ export const SessionApi = HttpApi.make("session")
           params: { sessionID: SessionID },
           params: { sessionID: SessionID },
           payload: UpdatePayload,
           payload: UpdatePayload,
           success: described(Session.Info, "Successfully updated session"),
           success: described(Session.Info, "Successfully updated session"),
-          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
+          error: [HttpApiError.BadRequest, ApiNotFoundError],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "session.update",
             identifier: "session.update",
@@ -225,6 +226,7 @@ export const SessionApi = HttpApi.make("session")
           params: { sessionID: SessionID },
           params: { sessionID: SessionID },
           payload: ForkPayload,
           payload: ForkPayload,
           success: described(Session.Info, "200"),
           success: described(Session.Info, "200"),
+          error: ApiNotFoundError,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "session.fork",
             identifier: "session.fork",
@@ -259,7 +261,7 @@ export const SessionApi = HttpApi.make("session")
         HttpApiEndpoint.post("share", SessionPaths.share, {
         HttpApiEndpoint.post("share", SessionPaths.share, {
           params: { sessionID: SessionID },
           params: { sessionID: SessionID },
           success: described(Session.Info, "Successfully shared session"),
           success: described(Session.Info, "Successfully shared session"),
-          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
+          error: [HttpApiError.BadRequest, ApiNotFoundError],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "session.share",
             identifier: "session.share",
@@ -270,7 +272,7 @@ export const SessionApi = HttpApi.make("session")
         HttpApiEndpoint.delete("unshare", SessionPaths.share, {
         HttpApiEndpoint.delete("unshare", SessionPaths.share, {
           params: { sessionID: SessionID },
           params: { sessionID: SessionID },
           success: described(Session.Info, "Successfully unshared session"),
           success: described(Session.Info, "Successfully unshared session"),
-          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
+          error: [HttpApiError.BadRequest, ApiNotFoundError],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "session.unshare",
             identifier: "session.unshare",
@@ -282,7 +284,7 @@ export const SessionApi = HttpApi.make("session")
           params: { sessionID: SessionID },
           params: { sessionID: SessionID },
           payload: SummarizePayload,
           payload: SummarizePayload,
           success: described(Schema.Boolean, "Summarized session"),
           success: described(Schema.Boolean, "Summarized session"),
-          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
+          error: [HttpApiError.BadRequest, ApiNotFoundError],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "session.summarize",
             identifier: "session.summarize",

+ 2 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts

@@ -4,6 +4,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e
 import { Authorization } from "../middleware/authorization"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
 import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
 import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
+import { ApiNotFoundError } from "../errors"
 import { described } from "./metadata"
 import { described } from "./metadata"
 
 
 const root = "/tui"
 const root = "/tui"
@@ -155,7 +156,7 @@ export const TuiApi = HttpApi.make("tui")
         HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
         HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
           payload: TuiEvent.SessionSelect.properties,
           payload: TuiEvent.SessionSelect.properties,
           success: described(Schema.Boolean, "Session selected successfully"),
           success: described(Schema.Boolean, "Session selected successfully"),
-          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
+          error: [HttpApiError.BadRequest, ApiNotFoundError],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "tui.selectSession",
             identifier: "tui.selectSession",

+ 4 - 3
packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts

@@ -15,6 +15,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
 import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
 import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
 import * as Socket from "effect/unstable/socket/Socket"
 import * as Socket from "effect/unstable/socket/Socket"
 import { InstanceHttpApi } from "../api"
 import { InstanceHttpApi } from "../api"
+import * as ApiError from "../errors"
 import { CursorQuery, Params, PtyPaths } from "../groups/pty"
 import { CursorQuery, Params, PtyPaths } from "../groups/pty"
 import { WebSocketTracker } from "../websocket-tracker"
 import { WebSocketTracker } from "../websocket-tracker"
 
 
@@ -46,7 +47,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
 
 
     const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
     const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
       const info = yield* pty.get(ctx.params.ptyID)
       const info = yield* pty.get(ctx.params.ptyID)
-      if (!info) return yield* new HttpApiError.NotFound({})
+      if (!info) return yield* ApiError.notFound("Session not found")
       return info
       return info
     })
     })
 
 
@@ -58,7 +59,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
         ...ctx.payload,
         ...ctx.payload,
         size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
         size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
       })
       })
-      if (!info) return yield* new HttpApiError.NotFound({})
+      if (!info) return yield* ApiError.notFound("Session not found")
       return info
       return info
     })
     })
 
 
@@ -71,7 +72,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
       const request = yield* HttpServerRequest.HttpServerRequest
       const request = yield* HttpServerRequest.HttpServerRequest
       if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
       if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
         return yield* new HttpApiError.Forbidden({})
         return yield* new HttpApiError.Forbidden({})
-      if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({})
+      if (!(yield* pty.get(ctx.params.ptyID))) return yield* ApiError.notFound("Session not found")
       return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
       return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
     })
     })
 
 

+ 9 - 0
packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts

@@ -0,0 +1,9 @@
+import type { NotFoundError as StorageNotFoundError } from "@/storage/storage"
+import { Effect } from "effect"
+import * as ApiError from "../errors"
+
+type StorageNotFound = InstanceType<typeof StorageNotFoundError>
+
+export function mapStorageNotFound<A, R>(self: Effect.Effect<A, StorageNotFound, R>) {
+  return self.pipe(Effect.mapError((error) => ApiError.notFound(error.data.message)))
+}

+ 49 - 56
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts

@@ -37,14 +37,7 @@ import {
   SummarizePayload,
   SummarizePayload,
   UpdatePayload,
   UpdatePayload,
 } from "../groups/session"
 } from "../groups/session"
-
-const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
-  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),
-    ),
-  )
+import * as SessionError from "./session-errors"
 
 
 export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
 export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
@@ -79,7 +72,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
     })
     })
 
 
     const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
     const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
-      return yield* mapNotFound(session.get(ctx.params.sessionID))
+      return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
     })
     })
 
 
     const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
     const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
@@ -101,51 +94,49 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
       params: { sessionID: SessionID }
       params: { sessionID: SessionID }
       query: typeof MessagesQuery.Type
       query: typeof MessagesQuery.Type
     }) {
     }) {
-      return yield* mapNotFound(
-        Effect.gen(function* () {
-          if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
-          if (ctx.query.before) {
-            const before = ctx.query.before
-            yield* Effect.try({
-              try: () => MessageV2.cursor.decode(before),
-              catch: () => new HttpApiError.BadRequest({}),
-            })
-          }
-          if (ctx.query.limit === undefined || ctx.query.limit === 0) {
-            yield* session.get(ctx.params.sessionID)
-            return yield* session.messages({ sessionID: ctx.params.sessionID })
-          }
-
-          yield* session.get(ctx.params.sessionID)
-          const page = MessageV2.page({
-            sessionID: ctx.params.sessionID,
-            limit: ctx.query.limit,
-            before: ctx.query.before,
-          })
-          if (!page.cursor) return page.items
-
-          const request = yield* HttpServerRequest.HttpServerRequest
-          // toURL() honors the Host + x-forwarded-proto headers, so the Link
-          // header echoes the real origin instead of a hard-coded localhost.
-          const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"))
-          url.searchParams.set("limit", ctx.query.limit.toString())
-          url.searchParams.set("before", page.cursor)
-          return HttpServerResponse.jsonUnsafe(page.items, {
-            headers: {
-              "Access-Control-Expose-Headers": "Link, X-Next-Cursor",
-              Link: `<${url.toString()}>; rel="next"`,
-              "X-Next-Cursor": page.cursor,
-            },
-          })
-        }),
-      )
+      if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
+      if (ctx.query.before) {
+        const before = ctx.query.before
+        yield* Effect.try({
+          try: () => MessageV2.cursor.decode(before),
+          catch: () => new HttpApiError.BadRequest({}),
+        })
+      }
+      yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
+      if (ctx.query.limit === undefined || ctx.query.limit === 0) {
+        return yield* session.messages({ sessionID: ctx.params.sessionID })
+      }
+
+      const page = MessageV2.page({
+        sessionID: ctx.params.sessionID,
+        limit: ctx.query.limit,
+        before: ctx.query.before,
+      })
+      if (!page.cursor) return page.items
+
+      const request = yield* HttpServerRequest.HttpServerRequest
+      // toURL() honors the Host + x-forwarded-proto headers, so the Link
+      // header echoes the real origin instead of a hard-coded localhost.
+      const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"))
+      url.searchParams.set("limit", ctx.query.limit.toString())
+      url.searchParams.set("before", page.cursor)
+      return HttpServerResponse.jsonUnsafe(page.items, {
+        headers: {
+          "Access-Control-Expose-Headers": "Link, X-Next-Cursor",
+          Link: `<${url.toString()}>; rel="next"`,
+          "X-Next-Cursor": page.cursor,
+        },
+      })
     })
     })
 
 
     const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
     const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
       params: { sessionID: SessionID; messageID: MessageID }
       params: { sessionID: SessionID; messageID: MessageID }
     }) {
     }) {
-      return yield* mapNotFound(
-        Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })),
+      return yield* SessionError.mapStorageNotFound(
+        Effect.try({
+          try: () => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
+          catch: (error) => error,
+        }).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))),
       )
       )
     })
     })
 
 
@@ -170,7 +161,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
     })
     })
 
 
     const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
     const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
-      yield* session.remove(ctx.params.sessionID)
+      yield* SessionError.mapStorageNotFound(session.remove(ctx.params.sessionID))
       return true
       return true
     })
     })
 
 
@@ -178,7 +169,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
       params: { sessionID: SessionID }
       params: { sessionID: SessionID }
       payload: typeof UpdatePayload.Type
       payload: typeof UpdatePayload.Type
     }) {
     }) {
-      const current = yield* session.get(ctx.params.sessionID)
+      const current = yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
       if (ctx.payload.title !== undefined) {
       if (ctx.payload.title !== undefined) {
         yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
         yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
       }
       }
@@ -191,14 +182,16 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
       if (ctx.payload.time?.archived !== undefined) {
       if (ctx.payload.time?.archived !== undefined) {
         yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
         yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
       }
       }
-      return yield* session.get(ctx.params.sessionID)
+      return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
     })
     })
 
 
     const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
     const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
       params: { sessionID: SessionID }
       params: { sessionID: SessionID }
       payload: typeof ForkPayload.Type
       payload: typeof ForkPayload.Type
     }) {
     }) {
-      return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })
+      return yield* SessionError.mapStorageNotFound(
+        session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }),
+      )
     })
     })
 
 
     const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
     const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
@@ -222,19 +215,19 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
 
 
     const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
     const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
       yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
       yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
-      return yield* session.get(ctx.params.sessionID)
+      return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
     })
     })
 
 
     const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
     const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
       yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
       yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
-      return yield* session.get(ctx.params.sessionID)
+      return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
     })
     })
 
 
     const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
     const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
       params: { sessionID: SessionID }
       params: { sessionID: SessionID }
       payload: typeof SummarizePayload.Type
       payload: typeof SummarizePayload.Type
     }) {
     }) {
-      yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
+      yield* revertSvc.cleanup(yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)))
       const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
       const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
       const defaultAgent = yield* agentSvc.defaultAgent()
       const defaultAgent = yield* agentSvc.defaultAgent()
       const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
       const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent

+ 4 - 9
packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts

@@ -1,13 +1,12 @@
 import { Bus } from "@/bus"
 import { Bus } from "@/bus"
 import { TuiEvent } from "@/cli/cmd/tui/event"
 import { TuiEvent } from "@/cli/cmd/tui/event"
-import { SessionTable } from "@/session/session.sql"
-import * as Database from "@/storage/db"
-import { eq } from "drizzle-orm"
+import { Session } from "@/session/session"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
 import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
 import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control"
 import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control"
 import { InstanceHttpApi } from "../api"
 import { InstanceHttpApi } from "../api"
 import { CommandPayload, TuiPublishPayload } from "../groups/tui"
 import { CommandPayload, TuiPublishPayload } from "../groups/tui"
+import * as SessionError from "./session-errors"
 
 
 const commandAliases = {
 const commandAliases = {
   session_new: "session.new",
   session_new: "session.new",
@@ -28,6 +27,7 @@ const commandAliases = {
 export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
 export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
     const bus = yield* Bus.Service
     const bus = yield* Bus.Service
+    const session = yield* Session.Service
     const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) =>
     const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) =>
       bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type)
       bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type)
 
 
@@ -98,12 +98,7 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler
       payload: typeof TuiEvent.SessionSelect.properties.Type
       payload: typeof TuiEvent.SessionSelect.properties.Type
     }) {
     }) {
       if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
       if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
-      const row = yield* Effect.sync(() =>
-        Database.use((db) =>
-          db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),
-        ),
-      )
-      if (!row) return yield* new HttpApiError.NotFound({})
+      yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sessionID))
       yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
       yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
       return true
       return true
     })
     })

+ 5 - 1
packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts

@@ -7,6 +7,7 @@ import { Session } from "@/session/session"
 import { HttpApiProxy } from "./proxy"
 import { HttpApiProxy } from "./proxy"
 import * as Fence from "@/server/shared/fence"
 import * as Fence from "@/server/shared/fence"
 import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
 import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
+import { NotFoundError } from "@/storage/storage"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Context, Data, Effect, Layer } from "effect"
 import { Context, Data, Effect, Layer } from "effect"
 import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
 import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -178,7 +179,10 @@ function routeHttpApiWorkspace<E>(
     const request = yield* HttpServerRequest.HttpServerRequest
     const request = yield* HttpServerRequest.HttpServerRequest
     const sessionID = getWorkspaceRouteSessionID(requestURL(request))
     const sessionID = getWorkspaceRouteSessionID(requestURL(request))
     const session = sessionID
     const session = sessionID
-      ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void))
+      ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
+          Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)),
+          Effect.catchDefect(() => Effect.succeed(undefined)),
+        )
       : undefined
       : undefined
     const plan = yield* planRequest(request, session?.workspaceID)
     const plan = yield* planRequest(request, session?.workspaceID)
     return yield* routeWorkspace(client, effect, plan)
     return yield* routeWorkspace(client, effect, plan)

+ 4 - 4
packages/opencode/src/session/prompt.ts

@@ -744,7 +744,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
           const markReady = ready ? ready.open.pipe(Effect.asVoid) : Effect.void
           const markReady = ready ? ready.open.pipe(Effect.asVoid) : Effect.void
           const { msg, part, cwd } = yield* Effect.gen(function* () {
           const { msg, part, cwd } = yield* Effect.gen(function* () {
             const ctx = yield* InstanceState.context
             const ctx = yield* InstanceState.context
-            const session = yield* sessions.get(input.sessionID)
+            const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
             if (session.revert) {
             if (session.revert) {
               yield* revert.cleanup(session)
               yield* revert.cleanup(session)
             }
             }
@@ -1370,7 +1370,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
 
 
     const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
     const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
       function* (input: PromptInput) {
       function* (input: PromptInput) {
-        const session = yield* sessions.get(input.sessionID)
+        const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
         yield* revert.cleanup(session)
         yield* revert.cleanup(session)
         const message = yield* createUserMessage(input)
         const message = yield* createUserMessage(input)
         yield* sessions.touch(input.sessionID)
         yield* sessions.touch(input.sessionID)
@@ -1401,9 +1401,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       function* (sessionID: SessionID) {
       function* (sessionID: SessionID) {
         const ctx = yield* InstanceState.context
         const ctx = yield* InstanceState.context
         const slog = elog.with({ sessionID })
         const slog = elog.with({ sessionID })
-        let structured: unknown | undefined
+        let structured: unknown
         let step = 0
         let step = 0
-        const session = yield* sessions.get(sessionID)
+        const session = yield* sessions.get(sessionID).pipe(Effect.orDie)
 
 
         while (true) {
         while (true) {
           yield* status.set(sessionID, { type: "busy" })
           yield* status.set(sessionID, { type: "busy" })

+ 7 - 7
packages/opencode/src/session/revert.ts

@@ -44,7 +44,7 @@ export const layer = Layer.effect(
       yield* state.assertNotBusy(input.sessionID)
       yield* state.assertNotBusy(input.sessionID)
       const all = yield* sessions.messages({ sessionID: input.sessionID })
       const all = yield* sessions.messages({ sessionID: input.sessionID })
       let lastUser: MessageV2.User | undefined
       let lastUser: MessageV2.User | undefined
-      const session = yield* sessions.get(input.sessionID)
+      const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
 
 
       let rev: Session.Info["revert"]
       let rev: Session.Info["revert"]
       const patches: Snapshot.Patch[] = []
       const patches: Snapshot.Patch[] = []
@@ -75,8 +75,8 @@ export const layer = Layer.effect(
       rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
       rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
       if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
       if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
       yield* snap.revert(patches)
       yield* snap.revert(patches)
-      if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
-      const range = all.filter((msg) => msg.info.id >= rev!.messageID)
+      if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
+      const range = all.filter((msg) => msg.info.id >= rev.messageID)
       const diffs = yield* summary.computeDiff({ messages: range })
       const diffs = yield* summary.computeDiff({ messages: range })
       yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
       yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
       yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
       yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
@@ -89,17 +89,17 @@ export const layer = Layer.effect(
           files: diffs.length,
           files: diffs.length,
         },
         },
       })
       })
-      return yield* sessions.get(input.sessionID)
+      return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
     })
     })
 
 
     const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
     const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
       log.info("unreverting", input)
       log.info("unreverting", input)
       yield* state.assertNotBusy(input.sessionID)
       yield* state.assertNotBusy(input.sessionID)
-      const session = yield* sessions.get(input.sessionID)
+      const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
       if (!session.revert) return session
       if (!session.revert) return session
-      if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
+      if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
       yield* sessions.clearRevert(input.sessionID)
       yield* sessions.clearRevert(input.sessionID)
-      return yield* sessions.get(input.sessionID)
+      return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
     })
     })
 
 
     const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
     const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {

+ 8 - 7
packages/opencode/src/session/session.ts

@@ -3,7 +3,6 @@ import path from "path"
 import { BusEvent } from "@/bus/bus-event"
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
 import { Bus } from "@/bus"
 import { Decimal } from "decimal.js"
 import { Decimal } from "decimal.js"
-import z from "zod"
 import { type ProviderMetadata, type LanguageModelUsage } from "ai"
 import { type ProviderMetadata, type LanguageModelUsage } from "ai"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -422,6 +421,8 @@ export class BusyError extends Error {
   }
   }
 }
 }
 
 
+export type NotFound = InstanceType<typeof NotFoundError>
+
 export interface Interface {
 export interface Interface {
   readonly list: (input?: ListInput) => Effect.Effect<Info[]>
   readonly list: (input?: ListInput) => Effect.Effect<Info[]>
   readonly create: (input?: {
   readonly create: (input?: {
@@ -432,9 +433,9 @@ export interface Interface {
     permission?: Permission.Ruleset
     permission?: Permission.Ruleset
     workspaceID?: WorkspaceID
     workspaceID?: WorkspaceID
   }) => Effect.Effect<Info>
   }) => Effect.Effect<Info>
-  readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
+  readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
   readonly touch: (sessionID: SessionID) => Effect.Effect<void>
   readonly touch: (sessionID: SessionID) => Effect.Effect<void>
-  readonly get: (id: SessionID) => Effect.Effect<Info>
+  readonly get: (id: SessionID) => Effect.Effect<Info, NotFound>
   readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
   readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
   readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
   readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
   readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
   readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
@@ -448,7 +449,7 @@ export interface Interface {
   readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
   readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
   readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<MessageV2.WithParts[]>
   readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<MessageV2.WithParts[]>
   readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
   readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
-  readonly remove: (sessionID: SessionID) => Effect.Effect<void>
+  readonly remove: (sessionID: SessionID) => Effect.Effect<void, NotFound>
   readonly updateMessage: <T extends MessageV2.Info>(msg: T) => Effect.Effect<T>
   readonly updateMessage: <T extends MessageV2.Info>(msg: T) => Effect.Effect<T>
   readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<MessageID>
   readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<MessageID>
   readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect<PartID>
   readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect<PartID>
@@ -534,13 +535,13 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
 
 
     const get = Effect.fn("Session.get")(function* (id: SessionID) {
     const get = Effect.fn("Session.get")(function* (id: SessionID) {
       const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
       const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
-      if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
+      if (!row) return yield* Effect.fail(new NotFoundError({ message: `Session not found: ${id}` }))
       return fromRow(row)
       return fromRow(row)
     })
     })
 
 
     const list = Effect.fn("Session.list")(function* (input?: ListInput) {
     const list = Effect.fn("Session.list")(function* (input?: ListInput) {
       const ctx = yield* InstanceState.context
       const ctx = yield* InstanceState.context
-      return Array.from(listByProject({ projectID: ctx.project.id, ...(input ?? {}) }))
+      return Array.from(listByProject({ projectID: ctx.project.id, ...input }))
     })
     })
 
 
     const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
     const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
@@ -555,8 +556,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
     })
     })
 
 
     const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
     const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
+      const session = yield* get(sessionID)
       try {
       try {
-        const session = yield* get(sessionID)
         const kids = yield* children(sessionID)
         const kids = yield* children(sessionID)
         for (const child of kids) {
         for (const child of kids) {
           yield* remove(child.id)
           yield* remove(child.id)

+ 2 - 2
packages/opencode/test/control-plane/workspace.test.ts

@@ -1060,7 +1060,7 @@ describe("workspace-old sync state", () => {
 
 
               yield* eventuallyEffect(
               yield* eventuallyEffect(
                 Effect.gen(function* () {
                 Effect.gen(function* () {
-                  expect((yield* sessionSvc.get(session.id)).title).toBe("from history")
+                  expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from history")
                 }),
                 }),
               )
               )
               expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
               expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
@@ -1208,7 +1208,7 @@ describe("workspace-old sync state", () => {
 
 
               yield* eventuallyEffect(
               yield* eventuallyEffect(
                 Effect.gen(function* () {
                 Effect.gen(function* () {
-                  expect((yield* sessionSvc.get(session.id)).title).toBe("from sse")
+                  expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from sse")
                 }),
                 }),
               )
               )
               expect(
               expect(

+ 11 - 12
packages/opencode/test/server/httpapi-parity.test.ts

@@ -105,23 +105,22 @@ 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 response body shape should match Hono's public NamedError
+// envelope `{ name, data: { message } }`. SDK consumers read
+// `error.data.message`, so returning an Effect built-in `{ _tag }` body is a
+// compatibility break.
 // ──────────────────────────────────────────────────────────────────────────────
 // ──────────────────────────────────────────────────────────────────────────────
 describe("Error JSON shape parity", () => {
 describe("Error JSON shape parity", () => {
-  test.todo("HttpApi 404 body matches NamedError shape", async () => {
+  test("HttpApi 404 body matches Hono shape", async () => {
     await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
     await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
+    const headers = { "x-opencode-directory": tmp.path }
 
 
-    const response = await app(true).request("/session/ses_does_not_exist", {
-      headers: { "x-opencode-directory": tmp.path },
-    })
+    const hono = await app(false).request("/session/ses_does_not_exist", { headers })
+    const httpapi = await app(true).request("/session/ses_does_not_exist", { headers })
 
 
-    expect(response.status).toBe(404)
-    const body = (await response.json()) as { name?: string; data?: { message?: string } }
+    expect(httpapi.status).toBe(hono.status)
+    const body = (await httpapi.json()) as { name?: string; data?: { message?: string } }
+    expect(body).toEqual(await hono.json())
     expect(body.name).toBe("NotFoundError")
     expect(body.name).toBe("NotFoundError")
     expect(typeof body.data?.message).toBe("string")
     expect(typeof body.data?.message).toBe("string")
   })
   })

+ 15 - 3
packages/opencode/test/server/httpapi-pty.test.ts

@@ -50,9 +50,9 @@ const effectIt = testEffect(
   ),
   ),
 )
 )
 
 
-function app() {
-  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
-  return Server.Default().app
+function app(experimental = true) {
+  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
+  return experimental ? Server.Default().app : Server.Legacy().app
 }
 }
 
 
 function serverUrl() {
 function serverUrl() {
@@ -121,6 +121,18 @@ describe("pty HttpApi bridge", () => {
     expect(missing.status).toBe(404)
     expect(missing.status).toBe(404)
   })
   })
 
 
+  test("matches Hono missing PTY error body", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const headers = { "x-opencode-directory": tmp.path }
+    const path = PtyPaths.get.replace(":ptyID", PtyID.ascending())
+
+    const hono = await app(false).request(path, { headers })
+    const httpapi = await app().request(path, { headers })
+
+    expect(httpapi.status).toBe(hono.status)
+    expect(await httpapi.json()).toEqual(await hono.json())
+  })
+
   test("returns 404 for missing PTY websocket before upgrade", async () => {
   test("returns 404 for missing PTY websocket before upgrade", async () => {
     await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
     await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
     const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {
     const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {

+ 62 - 7
packages/opencode/test/server/httpapi-sdk.test.ts

@@ -4,6 +4,7 @@ import type * as Scope from "effect/Scope"
 import { HttpRouter } from "effect/unstable/http"
 import { HttpRouter } from "effect/unstable/http"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { validateSession } from "../../src/cli/cmd/tui/validate-session"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
 import { WithInstance } from "../../src/project/with-instance"
 import { WithInstance } from "../../src/project/with-instance"
 import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
 import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
@@ -13,6 +14,7 @@ import { MessageV2 } from "../../src/session/message-v2"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import type { Config } from "@/config/config"
 import type { Config } from "@/config/config"
 import { Session as SessionNs } from "@/session/session"
 import { Session as SessionNs } from "@/session/session"
+import { errorMessage } from "../../src/util/error"
 import { TestLLMServer } from "../lib/llm-server"
 import { TestLLMServer } from "../lib/llm-server"
 import path from "path"
 import path from "path"
 import { resetDatabase } from "../fixture/db"
 import { resetDatabase } from "../fixture/db"
@@ -64,20 +66,23 @@ function client(
   directory?: string,
   directory?: string,
   input?: { password?: string; username?: string; headers?: Record<string, string> },
   input?: { password?: string; username?: string; headers?: Record<string, string> },
 ) {
 ) {
-  const serverApp = app(backend, input)
-  const fetch = Object.assign(
-    async (request: RequestInfo | URL, init?: RequestInit) =>
-      await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
-    { preconnect: globalThis.fetch.preconnect },
-  ) satisfies typeof globalThis.fetch
   return createOpencodeClient({
   return createOpencodeClient({
     baseUrl: "http://localhost",
     baseUrl: "http://localhost",
     directory,
     directory,
     headers: input?.headers,
     headers: input?.headers,
-    fetch,
+    fetch: serverFetch(backend, input),
   })
   })
 }
 }
 
 
+function serverFetch(backend: Backend, input?: { password?: string; username?: string }) {
+  const serverApp = app(backend, input)
+  return Object.assign(
+    async (request: RequestInfo | URL, init?: RequestInit) =>
+      await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
+    { preconnect: globalThis.fetch.preconnect },
+  ) satisfies typeof globalThis.fetch
+}
+
 function authorization(username: string, password: string) {
 function authorization(username: string, password: string) {
   return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
   return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
 }
 }
@@ -129,6 +134,16 @@ function capture(request: () => Promise<SdkResult>) {
   )
   )
 }
 }
 
 
+function captureThrown(request: () => Promise<unknown>) {
+  return call(async () => {
+    try {
+      await request()
+    } catch (error) {
+      return error
+    }
+  })
+}
+
 function expectStatus(request: () => Promise<{ response: Response }>, status: number) {
 function expectStatus(request: () => Promise<{ response: Response }>, status: number) {
   return call(request).pipe(
   return call(request).pipe(
     Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))),
     Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))),
@@ -338,6 +353,46 @@ describe("HttpApi SDK", () => {
     ),
     ),
   )
   )
 
 
+  parity("matches generated SDK missing session errors across backends", (backend) =>
+    withStandardProject(backend, ({ sdk }) =>
+      Effect.gen(function* () {
+        const sessionID = "ses_missing"
+        const expected = {
+          name: "NotFoundError",
+          data: { message: `Session not found: ${sessionID}` },
+        }
+        const missing = yield* capture(() => sdk.session.get({ sessionID }))
+        const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
+
+        expect(missing.error).toEqual(expected)
+        expect(thrown).toEqual(expected)
+        return {
+          status: missing.status,
+          error: missing.error,
+          thrown,
+        }
+      }),
+    ),
+  )
+
+  parity("formats missing session validation errors for -s", (backend) =>
+    withStandardProject(backend, ({ directory }) =>
+      Effect.gen(function* () {
+        const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
+        const thrown = yield* captureThrown(() =>
+          validateSession({
+            url: "http://localhost",
+            directory,
+            sessionID,
+            fetch: serverFetch(backend),
+          }),
+        )
+        expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
+        return errorMessage(thrown)
+      }),
+    ),
+  )
+
   parity("matches generated SDK basic auth behavior across backends", (backend) =>
   parity("matches generated SDK basic auth behavior across backends", (backend) =>
     withStandardProject(backend, ({ directory }) =>
     withStandardProject(backend, ({ directory }) =>
       Effect.gen(function* () {
       Effect.gen(function* () {

+ 47 - 3
packages/opencode/test/server/httpapi-session.test.ts

@@ -8,13 +8,12 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import { Workspace } from "../../src/control-plane/workspace"
 import { Workspace } from "../../src/control-plane/workspace"
 import { PermissionID } from "../../src/permission/schema"
 import { PermissionID } from "../../src/permission/schema"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { ModelID, ProviderID } from "../../src/provider/schema"
-import { Instance } from "../../src/project/instance"
 import { WithInstance } from "../../src/project/with-instance"
 import { WithInstance } from "../../src/project/with-instance"
 import { Project } from "../../src/project/project"
 import { Project } from "../../src/project/project"
 import { Server } from "../../src/server/server"
 import { Server } from "../../src/server/server"
 import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
 import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
 import { Session } from "@/session/session"
 import { Session } from "@/session/session"
-import { MessageID, PartID, type SessionID } from "../../src/session/schema"
+import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
 import { MessageV2 } from "../../src/session/message-v2"
 import { MessageV2 } from "../../src/session/message-v2"
 import { Database } from "@/storage/db"
 import { Database } from "@/storage/db"
 import { SessionMessageTable, SessionTable } from "@/session/session.sql"
 import { SessionMessageTable, SessionTable } from "@/session/session.sql"
@@ -55,7 +54,7 @@ function createSession(directory: string, input?: Session.CreateInput) {
   )
   )
 }
 }
 
 
-function createTextMessage(directory: string, sessionID: SessionID, text: string) {
+function createTextMessage(directory: string, sessionID: SessionIDType, text: string) {
   return Effect.promise(
   return Effect.promise(
     async () =>
     async () =>
       await WithInstance.provide({
       await WithInstance.provide({
@@ -125,6 +124,10 @@ function json<T>(response: Response) {
   })
   })
 }
 }
 
 
+function responseJson(response: Response) {
+  return Effect.promise(() => response.json())
+}
+
 function requestJson<T>(path: string, init?: RequestInit) {
 function requestJson<T>(path: string, init?: RequestInit) {
   return request(path, init).pipe(Effect.flatMap(json<T>))
   return request(path, init).pipe(Effect.flatMap(json<T>))
 }
 }
@@ -147,6 +150,47 @@ afterEach(async () => {
 })
 })
 
 
 describe("session HttpApi", () => {
 describe("session HttpApi", () => {
+  it.live(
+    "returns declared not found errors for read routes",
+    withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const headers = { "x-opencode-directory": tmp.path }
+        const missingSession = SessionID.descending()
+        const missingSessionBody = {
+          name: "NotFoundError",
+          data: { message: `Session not found: ${missingSession}` },
+        }
+
+        const get = yield* request(pathFor(SessionPaths.get, { sessionID: missingSession }), { headers })
+        expect(get.status).toBe(404)
+        expect(yield* responseJson(get)).toEqual(missingSessionBody)
+
+        const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers })
+        expect(messages.status).toBe(404)
+        expect(yield* responseJson(messages)).toEqual(missingSessionBody)
+
+        const remove = yield* request(pathFor(SessionPaths.remove, { sessionID: missingSession }), {
+          headers,
+          method: "DELETE",
+        })
+        expect(remove.status).toBe(404)
+        expect(yield* responseJson(remove)).toEqual(missingSessionBody)
+
+        const session = yield* createSession(tmp.path, { title: "missing message" })
+        const missingMessage = MessageID.ascending()
+        const message = yield* request(
+          pathFor(SessionPaths.message, { sessionID: session.id, messageID: missingMessage }),
+          { headers },
+        )
+        expect(message.status).toBe(404)
+        expect(yield* responseJson(message)).toEqual({
+          name: "NotFoundError",
+          data: { message: `Message not found: ${missingMessage}` },
+        })
+      }),
+    ),
+  )
+
   it.live(
   it.live(
     "serves read routes through Hono bridge",
     "serves read routes through Hono bridge",
     withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
     withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>

+ 14 - 1
packages/opencode/test/server/httpapi-tui.test.ts

@@ -72,14 +72,27 @@ describe("tui HttpApi bridge", () => {
       properties: { text: "from publish" },
       properties: { text: "from publish" },
     })
     })
 
 
+    const missingSessionID = SessionID.descending()
     const missing = await app().request(TuiPaths.selectSession, {
     const missing = await app().request(TuiPaths.selectSession, {
       method: "POST",
       method: "POST",
       headers: { ...headers, "content-type": "application/json" },
       headers: { ...headers, "content-type": "application/json" },
-      body: JSON.stringify({ sessionID: SessionID.descending() }),
+      body: JSON.stringify({ sessionID: missingSessionID }),
     })
     })
     expect(missing.status).toBe(404)
     expect(missing.status).toBe(404)
   })
   })
 
 
+  test("matches Hono missing selected session error body", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
+    const body = JSON.stringify({ sessionID: SessionID.descending() })
+
+    const hono = await app(false).request(TuiPaths.selectSession, { method: "POST", headers, body })
+    const httpapi = await app().request(TuiPaths.selectSession, { method: "POST", headers, body })
+
+    expect(httpapi.status).toBe(hono.status)
+    expect(await httpapi.json()).toEqual(await hono.json())
+  })
+
   test("matches legacy unknown execute command behavior", async () => {
   test("matches legacy unknown execute command behavior", async () => {
     await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
     await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
     const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
     const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }