Просмотр исходного кода

fix(server): return diagnosable body for schema rejections (#26631)

Kit Langton 3 месяцев назад
Родитель
Сommit
049502fac6

+ 3 - 0
packages/opencode/src/server/routes/instance/httpapi/api.ts

@@ -21,6 +21,7 @@ import { TuiApi } from "./groups/tui"
 import { WorkspaceApi } from "./groups/workspace"
 import { V2Api } from "./groups/v2"
 import { Authorization } from "./middleware/authorization"
+import { SchemaErrorMiddleware } from "./middleware/schema-error"
 
 // SSE event schemas built from the BusEvent/SyncEvent registries.
 const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
@@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads()
 export const RootHttpApi = HttpApi.make("opencode-root")
   .addHttpApi(ControlApi)
   .addHttpApi(GlobalApi)
+  .middleware(SchemaErrorMiddleware)
   .middleware(Authorization)
 
 export const InstanceHttpApi = HttpApi.make("opencode-instance")
@@ -47,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
   .addHttpApi(V2Api)
   .addHttpApi(TuiApi)
   .addHttpApi(WorkspaceApi)
+  .middleware(SchemaErrorMiddleware)
 
 export const OpenCodeHttpApi = HttpApi.make("opencode")
   .addHttpApi(RootHttpApi)

+ 36 - 0
packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts

@@ -0,0 +1,36 @@
+import { Effect } from "effect"
+import { HttpServerResponse } from "effect/unstable/http"
+import { HttpApiMiddleware } from "effect/unstable/httpapi"
+import * as Log from "@opencode-ai/core/util/log"
+
+const log = Log.create({ service: "server" })
+
+// Effect's Issue formatter recursively dumps the rejected `actual` value with
+// no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep
+// 4xx responses small and avoid mirroring entire request payloads (which may
+// contain secrets) into the response body and log file.
+const REASON_LIMIT = 1024
+function truncateReason(reason: string) {
+  if (reason.length <= REASON_LIMIT) return reason
+  return reason.slice(0, REASON_LIMIT) + `… (${reason.length - REASON_LIMIT} more chars)`
+}
+
+// Default Respondable returns an empty 400 body. Match the NamedError shape
+// used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`.
+export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
+  "@opencode/HttpApiSchemaError",
+) {}
+
+export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(
+  SchemaErrorMiddleware,
+  (error) => {
+    const reason = truncateReason(error.cause.message)
+    log.warn("schema rejection", { kind: error.kind, reason })
+    return Effect.succeed(
+      HttpServerResponse.jsonUnsafe(
+        { name: "BadRequest", data: { message: reason, kind: error.kind } },
+        { status: 400 },
+      ),
+    )
+  },
+)

+ 11 - 8
packages/opencode/src/server/routes/instance/httpapi/public.ts

@@ -178,17 +178,20 @@ function addLegacyErrorSchemas(spec: OpenApiSpec) {
   if (!spec.components?.schemas) return
   spec.components.schemas.BadRequestError = {
     type: "object",
-    required: ["data", "errors", "success"],
+    required: ["name", "data"],
     properties: {
-      data: {},
-      errors: {
-        type: "array",
-        items: {
-          type: "object",
-          additionalProperties: {},
+      name: { type: "string", enum: ["BadRequest"] },
+      data: {
+        type: "object",
+        required: ["message"],
+        properties: {
+          message: { type: "string" },
+          kind: {
+            type: "string",
+            enum: ["Params", "Headers", "Query", "Body", "Payload"],
+          },
         },
       },
-      success: { type: "boolean", enum: [false] },
     },
   }
   spec.components.schemas.NotFoundError = {

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

@@ -84,6 +84,7 @@ import { compressionLayer } from "./middleware/compression"
 import { corsVaryFix } from "./middleware/cors-vary"
 import { errorLayer } from "./middleware/error"
 import { fenceLayer } from "./middleware/fence"
+import { schemaErrorLayer } from "./middleware/schema-error"
 
 export const context = Context.makeUnsafe<unknown>(new Map())
 
@@ -114,6 +115,7 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi
 const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
 const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
   Layer.provide([controlHandlers, globalHandlers]),
+  Layer.provide(schemaErrorLayer),
   Layer.provide(httpApiAuthLayer),
 )
 const instanceRouterLayer = authorizationRouterMiddleware
@@ -150,6 +152,7 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
     httpApiAuthLayer,
     workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
     instanceContextLayer,
+    schemaErrorLayer,
   ]),
 )
 

+ 162 - 0
packages/opencode/test/server/httpapi-schema-error-body.test.ts

@@ -0,0 +1,162 @@
+import { afterEach, describe, expect } from "bun:test"
+import { Effect } from "effect"
+import { eq } from "drizzle-orm"
+import * as Database from "@/storage/db"
+import { ModelID, ProviderID } from "../../src/provider/schema"
+import { WithInstance } from "../../src/project/with-instance"
+import { Server } from "../../src/server/server"
+import { Session } from "@/session/session"
+import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
+import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
+import { MessageID, PartID } from "../../src/session/schema"
+import { PartTable } from "@/session/session.sql"
+import { resetDatabase } from "../fixture/db"
+import { disposeAllInstances, tmpdir } from "../fixture/fixture"
+import { it } from "../lib/effect"
+
+afterEach(async () => {
+  await disposeAllInstances()
+  await resetDatabase()
+})
+
+const withTmp = <A, E, R>(
+  options: Parameters<typeof tmpdir>[0],
+  fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
+) =>
+  Effect.acquireRelease(
+    Effect.promise(() => tmpdir(options)),
+    (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+  ).pipe(Effect.flatMap(fn))
+
+async function seedCorruptStepFinishPart(directory: string) {
+  return WithInstance.provide({
+    directory,
+    fn: () =>
+      Effect.runPromise(
+        Effect.gen(function* () {
+          const session = yield* Session.Service
+          const info = yield* session.create({})
+          const message = yield* session.updateMessage({
+            id: MessageID.ascending(),
+            role: "user",
+            sessionID: info.id,
+            agent: "build",
+            model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
+            time: { created: Date.now() },
+          })
+          const partID = PartID.ascending()
+          yield* session.updatePart({
+            id: partID,
+            sessionID: info.id,
+            messageID: message.id,
+            type: "step-finish",
+            reason: "stop",
+            cost: 0,
+            tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+          })
+          // Schema.Finite still rejects NaN at encode — exact mirror of the
+          // corrupt row that broke the user's session in the OMO/Windows bug.
+          Database.use((db) =>
+            db
+              .update(PartTable)
+              .set({
+                data: {
+                  type: "step-finish",
+                  reason: "stop",
+                  cost: 0,
+                  tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
+                } as never, // drizzle's .set() can't narrow the discriminated union
+              })
+              .where(eq(PartTable.id, partID))
+              .run(),
+          )
+          return info.id
+        }).pipe(Effect.provide(Session.defaultLayer)),
+      ),
+  })
+}
+
+describe("schema-rejection wire shape", () => {
+  it.live(
+    "Payload schema rejection returns NamedError-shaped JSON, not empty",
+    withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const res = yield* Effect.promise(async () =>
+          Server.Default().app.request(SyncPaths.history, {
+            method: "POST",
+            headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
+            body: JSON.stringify({ aggregate: -1 }),
+          }),
+        )
+        const body = yield* Effect.promise(async () => res.text())
+        expect(res.status).toBe(400)
+        expect(res.headers.get("content-type") ?? "").toContain("application/json")
+        const parsed = JSON.parse(body)
+        expect(parsed).toMatchObject({
+          name: "BadRequest",
+          data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
+        })
+        expect(parsed.data.message).toEqual(expect.any(String))
+        expect(parsed.data.message.length).toBeGreaterThan(0)
+      }),
+    ),
+  )
+
+  it.live(
+    "Query schema rejection returns NamedError-shaped JSON",
+    withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        // /find/file?limit=999999 violates the limit constraint check.
+        const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}`
+        const res = yield* Effect.promise(async () => Server.Default().app.request(url))
+        const body = yield* Effect.promise(async () => res.text())
+        expect(res.status).toBe(400)
+        const parsed = JSON.parse(body)
+        expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
+      }),
+    ),
+  )
+
+  it.live(
+    "rejected request body never echoes back unbounded — message is capped",
+    // Defense against DoS-amplification + secret-echo: Effect's Issue formatter
+    // dumps the rejected `actual` verbatim. A multi-MB invalid array would
+    // become a multi-MB 400 response and log line. Cap kicks in around 1KB.
+    withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const huge = "X".repeat(50_000)
+        const res = yield* Effect.promise(async () =>
+          Server.Default().app.request(SyncPaths.history, {
+            method: "POST",
+            headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
+            body: JSON.stringify({ aggregate: huge }),
+          }),
+        )
+        const body = yield* Effect.promise(async () => res.text())
+        expect(res.status).toBe(400)
+        // 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB.
+        expect(body.length).toBeLessThan(2 * 1024)
+        const parsed = JSON.parse(body)
+        expect(parsed.data.message).not.toContain(huge)
+      }),
+    ),
+  )
+
+  it.live(
+    "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
+    withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path))
+        const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
+        const res = yield* Effect.promise(async () => Server.Default().app.request(url))
+        const body = yield* Effect.promise(async () => res.text())
+        expect(res.status).toBe(400)
+        expect(res.headers.get("content-type") ?? "").toContain("application/json")
+        const parsed = JSON.parse(body)
+        expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } })
+        // Field path in data.message — what made this PR worth shipping.
+        expect(parsed.data.message).toMatch(/output/)
+      }),
+    ),
+  )
+})

+ 16 - 6
packages/opencode/test/server/sdk-error-shape.test.ts

@@ -52,23 +52,33 @@ describe("v2 SDK error shape", () => {
     })
   })
 
-  test("400 with empty body throws a real Error naming the status", async () => {
+  test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => {
+    // Canary for the #26631 wire shape. Asserts the contract end-to-end:
+    // server emits {name:"BadRequest", data:{message, kind}}, SDK's
+    // wrapClientError extracts .data.message into Error.message. If either
+    // side regresses (#26457 reverted because both layers were missing),
+    // this test fails before users see (empty response body).
     await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
     const sdk = client(tmp.path)
 
     let caught: unknown
     try {
-      // POST /sync/history with `aggregate: -1` triggers schema validation
-      // that returns an empty 400 body (verified via plan-mode probe).
-      await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true })
+      await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true })
     } catch (e) {
       caught = e
     }
 
     expect(caught).toBeInstanceOf(Error)
     const err = caught as Error
-    const cause = err.cause as { status?: number }
-    expect(err.message.length).toBeGreaterThan(0)
+    const cause = err.cause as { body?: any; status?: number }
     expect(cause.status).toBe(400)
+    expect(cause.body).toMatchObject({
+      name: "BadRequest",
+      data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
+    })
+    expect(typeof cause.body.data.message).toBe("string")
+    expect(cause.body.data.message.length).toBeGreaterThan(0)
+    // Whatever the server put in data.message must be what the user sees.
+    expect(err.message).toBe(cause.body.data.message)
   })
 })

+ 60 - 0
packages/opencode/test/server/sdk-v1-smoke.test.ts

@@ -0,0 +1,60 @@
+// Smoke test: v1 SDK (the plugin contract) can actually reach core endpoints
+// against the current server. v1 generation has been frozen since #5216
+// (2025-12-07) so types may be stale, but runtime calls should still work
+// for endpoints the v1 SDK was generated against.
+import { afterEach, describe, expect, test } from "bun:test"
+import { createOpencodeClient } from "@opencode-ai/sdk"
+import { Server } from "../../src/server/server"
+import { tmpdir, disposeAllInstances } from "../fixture/fixture"
+import { resetDatabase } from "../fixture/db"
+import * as Log from "@opencode-ai/core/util/log"
+
+void Log.init({ print: false })
+
+afterEach(async () => {
+  await disposeAllInstances()
+  await resetDatabase()
+})
+
+function client(directory: string) {
+  return createOpencodeClient({
+    baseUrl: "http://test",
+    directory,
+    fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
+  })
+}
+
+describe("v1 SDK runtime smoke", () => {
+  test("session.list reaches the server and returns 200", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const sdk = client(tmp.path)
+    const result = await sdk.session.list()
+    expect(result.error).toBeUndefined()
+    expect(Array.isArray(result.data)).toBe(true)
+  })
+
+  test("path.get reaches the server and returns 200", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const sdk = client(tmp.path)
+    const result = await sdk.path.get()
+    expect(result.error).toBeUndefined()
+    expect(result.data).toBeDefined()
+  })
+
+  test("config.get reaches the server and returns 200", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const sdk = client(tmp.path)
+    const result = await sdk.config.get()
+    expect(result.error).toBeUndefined()
+    expect(result.data).toBeDefined()
+  })
+
+  test("session 404: result-tuple path returns the error body", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const sdk = client(tmp.path)
+    const result = await sdk.session.get({ path: { id: "ses_no_such" } as never })
+    expect(result.error).toBeDefined()
+    // wire body for 404 is NamedError-shaped
+    expect(result.error).toMatchObject({ name: "NotFoundError" })
+  })
+})

+ 5 - 5
packages/sdk/js/src/gen/types.gen.ts

@@ -752,11 +752,11 @@ export type Project = {
 }
 
 export type BadRequestError = {
-  data: unknown
-  errors: Array<{
-    [key: string]: unknown
-  }>
-  success: false
+  name: "BadRequest"
+  data: {
+    message: string
+    kind?: "Params" | "Headers" | "Query" | "Body" | "Payload"
+  }
 }
 
 export type NotFoundError = {

+ 88 - 88
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -5,6 +5,12 @@ export type ClientOptions = {
 }
 
 export type Event =
+  | EventTuiPromptAppend
+  | EventTuiCommandExecute
+  | EventTuiToastShow1
+  | EventTuiSessionSelect
+  | EventServerConnected
+  | EventGlobalDisposed
   | EventServerInstanceDisposed
   | EventFileEdited
   | EventFileWatcherUpdated
@@ -24,10 +30,6 @@ export type Event =
   | EventSessionStatus
   | EventSessionIdle
   | EventSessionCompacted
-  | EventTuiPromptAppend
-  | EventTuiCommandExecute
-  | EventTuiToastShow1
-  | EventTuiSessionSelect
   | EventMcpToolsChanged
   | EventMcpBrowserOpenFailed
   | EventCommandExecuted
@@ -75,8 +77,6 @@ export type Event =
   | EventSessionNextCompactionStarted
   | EventSessionNextCompactionDelta
   | EventSessionNextCompactionEnded
-  | EventServerConnected
-  | EventGlobalDisposed
 
 export type OAuth = {
   type: "oauth"
@@ -103,6 +103,61 @@ export type WellKnownAuth = {
 
 export type Auth = OAuth | ApiAuth | WellKnownAuth
 
+export type EventTuiPromptAppend = {
+  id: string
+  type: "tui.prompt.append"
+  properties: {
+    text: string
+  }
+}
+
+export type EventTuiCommandExecute = {
+  id: string
+  type: "tui.command.execute"
+  properties: {
+    command:
+      | "session.list"
+      | "session.new"
+      | "session.share"
+      | "session.interrupt"
+      | "session.compact"
+      | "session.page.up"
+      | "session.page.down"
+      | "session.line.up"
+      | "session.line.down"
+      | "session.half.page.up"
+      | "session.half.page.down"
+      | "session.first"
+      | "session.last"
+      | "prompt.clear"
+      | "prompt.submit"
+      | "agent.cycle"
+      | string
+  }
+}
+
+export type EventTuiToastShow = {
+  id: string
+  type: "tui.toast.show"
+  properties: {
+    title?: string
+    message: string
+    variant: "info" | "success" | "warning" | "error"
+    duration?: number
+  }
+}
+
+export type EventTuiSessionSelect = {
+  id: string
+  type: "tui.session.select"
+  properties: {
+    /**
+     * Session ID to navigate to
+     */
+    sessionID: string
+  }
+}
+
 export type PermissionRequest = {
   id: string
   sessionID: string
@@ -280,61 +335,6 @@ export type SessionStatus =
       type: "busy"
     }
 
-export type EventTuiPromptAppend = {
-  id: string
-  type: "tui.prompt.append"
-  properties: {
-    text: string
-  }
-}
-
-export type EventTuiCommandExecute = {
-  id: string
-  type: "tui.command.execute"
-  properties: {
-    command:
-      | "session.list"
-      | "session.new"
-      | "session.share"
-      | "session.interrupt"
-      | "session.compact"
-      | "session.page.up"
-      | "session.page.down"
-      | "session.line.up"
-      | "session.line.down"
-      | "session.half.page.up"
-      | "session.half.page.down"
-      | "session.first"
-      | "session.last"
-      | "prompt.clear"
-      | "prompt.submit"
-      | "agent.cycle"
-      | string
-  }
-}
-
-export type EventTuiToastShow = {
-  id: string
-  type: "tui.toast.show"
-  properties: {
-    title?: string
-    message: string
-    variant: "info" | "success" | "warning" | "error"
-    duration?: number
-  }
-}
-
-export type EventTuiSessionSelect = {
-  id: string
-  type: "tui.session.select"
-  properties: {
-    /**
-     * Session ID to navigate to
-     */
-    sessionID: string
-  }
-}
-
 export type Project = {
   id: string
   worktree: string
@@ -778,6 +778,12 @@ export type GlobalEvent = {
   project?: string
   workspace?: string
   payload:
+    | EventTuiPromptAppend
+    | EventTuiCommandExecute
+    | EventTuiToastShow
+    | EventTuiSessionSelect
+    | EventServerConnected
+    | EventGlobalDisposed
     | EventServerInstanceDisposed
     | EventFileEdited
     | EventFileWatcherUpdated
@@ -797,10 +803,6 @@ export type GlobalEvent = {
     | EventSessionStatus
     | EventSessionIdle
     | EventSessionCompacted
-    | EventTuiPromptAppend
-    | EventTuiCommandExecute
-    | EventTuiToastShow
-    | EventTuiSessionSelect
     | EventMcpToolsChanged
     | EventMcpBrowserOpenFailed
     | EventCommandExecuted
@@ -848,8 +850,6 @@ export type GlobalEvent = {
     | EventSessionNextCompactionStarted
     | EventSessionNextCompactionDelta
     | EventSessionNextCompactionEnded
-    | EventServerConnected
-    | EventGlobalDisposed
     | SyncEventMessageUpdated
     | SyncEventMessageRemoved
     | SyncEventMessagePartUpdated
@@ -2330,6 +2330,22 @@ export type SyncEventSessionNextCompactionEnded = {
   }
 }
 
+export type EventServerConnected = {
+  id: string
+  type: "server.connected"
+  properties: {
+    [key: string]: unknown
+  }
+}
+
+export type EventGlobalDisposed = {
+  id: string
+  type: "global.disposed"
+  properties: {
+    [key: string]: unknown
+  }
+}
+
 export type EventServerInstanceDisposed = {
   id: string
   type: "server.instance.disposed"
@@ -3044,22 +3060,6 @@ export type EventSessionNextCompactionEnded = {
   }
 }
 
-export type EventServerConnected = {
-  id: string
-  type: "server.connected"
-  properties: {
-    [key: string]: unknown
-  }
-}
-
-export type EventGlobalDisposed = {
-  id: string
-  type: "global.disposed"
-  properties: {
-    [key: string]: unknown
-  }
-}
-
 export type SessionInfo = {
   id: string
   parentID?: string
@@ -3296,11 +3296,11 @@ export type EventTuiToastShow1 = {
 }
 
 export type BadRequestError = {
-  data: unknown
-  errors: Array<{
-    [key: string]: unknown
-  }>
-  success: false
+  name: "BadRequest"
+  data: {
+    message: string
+    kind?: "Params" | "Headers" | "Query" | "Body" | "Payload"
+  }
 }
 
 export type AuthRemoveData = {

+ 222 - 217
packages/sdk/openapi.json

@@ -8868,6 +8868,24 @@
     "schemas": {
       "Event": {
         "anyOf": [
+          {
+            "$ref": "#/components/schemas/Event.tui.prompt.append"
+          },
+          {
+            "$ref": "#/components/schemas/Event.tui.command.execute"
+          },
+          {
+            "$ref": "#/components/schemas/EventTuiToastShow1"
+          },
+          {
+            "$ref": "#/components/schemas/Event.tui.session.select"
+          },
+          {
+            "$ref": "#/components/schemas/EventServerConnected"
+          },
+          {
+            "$ref": "#/components/schemas/EventGlobalDisposed"
+          },
           {
             "$ref": "#/components/schemas/EventServerInstanceDisposed"
           },
@@ -8925,18 +8943,6 @@
           {
             "$ref": "#/components/schemas/EventSessionCompacted"
           },
-          {
-            "$ref": "#/components/schemas/Event.tui.prompt.append"
-          },
-          {
-            "$ref": "#/components/schemas/Event.tui.command.execute"
-          },
-          {
-            "$ref": "#/components/schemas/EventTuiToastShow1"
-          },
-          {
-            "$ref": "#/components/schemas/Event.tui.session.select"
-          },
           {
             "$ref": "#/components/schemas/EventMcpToolsChanged"
           },
@@ -9077,12 +9083,6 @@
           },
           {
             "$ref": "#/components/schemas/EventSessionNextCompactionEnded"
-          },
-          {
-            "$ref": "#/components/schemas/EventServerConnected"
-          },
-          {
-            "$ref": "#/components/schemas/EventGlobalDisposed"
           }
         ]
       },
@@ -9163,6 +9163,140 @@
           }
         ]
       },
+      "Event.tui.prompt.append": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string"
+          },
+          "type": {
+            "type": "string",
+            "enum": ["tui.prompt.append"]
+          },
+          "properties": {
+            "type": "object",
+            "properties": {
+              "text": {
+                "type": "string"
+              }
+            },
+            "required": ["text"],
+            "additionalProperties": false
+          }
+        },
+        "required": ["id", "type", "properties"],
+        "additionalProperties": false
+      },
+      "Event.tui.command.execute": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string"
+          },
+          "type": {
+            "type": "string",
+            "enum": ["tui.command.execute"]
+          },
+          "properties": {
+            "type": "object",
+            "properties": {
+              "command": {
+                "anyOf": [
+                  {
+                    "type": "string",
+                    "enum": [
+                      "session.list",
+                      "session.new",
+                      "session.share",
+                      "session.interrupt",
+                      "session.compact",
+                      "session.page.up",
+                      "session.page.down",
+                      "session.line.up",
+                      "session.line.down",
+                      "session.half.page.up",
+                      "session.half.page.down",
+                      "session.first",
+                      "session.last",
+                      "prompt.clear",
+                      "prompt.submit",
+                      "agent.cycle"
+                    ]
+                  },
+                  {
+                    "type": "string"
+                  }
+                ]
+              }
+            },
+            "required": ["command"],
+            "additionalProperties": false
+          }
+        },
+        "required": ["id", "type", "properties"],
+        "additionalProperties": false
+      },
+      "Event.tui.toast.show": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string"
+          },
+          "type": {
+            "type": "string",
+            "enum": ["tui.toast.show"]
+          },
+          "properties": {
+            "type": "object",
+            "properties": {
+              "title": {
+                "type": "string"
+              },
+              "message": {
+                "type": "string"
+              },
+              "variant": {
+                "type": "string",
+                "enum": ["info", "success", "warning", "error"]
+              },
+              "duration": {
+                "type": "integer",
+                "exclusiveMinimum": 0
+              }
+            },
+            "required": ["message", "variant"],
+            "additionalProperties": false
+          }
+        },
+        "required": ["id", "type", "properties"],
+        "additionalProperties": false
+      },
+      "Event.tui.session.select": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string"
+          },
+          "type": {
+            "type": "string",
+            "enum": ["tui.session.select"]
+          },
+          "properties": {
+            "type": "object",
+            "properties": {
+              "sessionID": {
+                "type": "string",
+                "pattern": "^ses",
+                "description": "Session ID to navigate to"
+              }
+            },
+            "required": ["sessionID"],
+            "additionalProperties": false
+          }
+        },
+        "required": ["id", "type", "properties"],
+        "additionalProperties": false
+      },
       "PermissionRequest": {
         "type": "object",
         "properties": {
@@ -9622,140 +9756,6 @@
           }
         ]
       },
-      "Event.tui.prompt.append": {
-        "type": "object",
-        "properties": {
-          "id": {
-            "type": "string"
-          },
-          "type": {
-            "type": "string",
-            "enum": ["tui.prompt.append"]
-          },
-          "properties": {
-            "type": "object",
-            "properties": {
-              "text": {
-                "type": "string"
-              }
-            },
-            "required": ["text"],
-            "additionalProperties": false
-          }
-        },
-        "required": ["id", "type", "properties"],
-        "additionalProperties": false
-      },
-      "Event.tui.command.execute": {
-        "type": "object",
-        "properties": {
-          "id": {
-            "type": "string"
-          },
-          "type": {
-            "type": "string",
-            "enum": ["tui.command.execute"]
-          },
-          "properties": {
-            "type": "object",
-            "properties": {
-              "command": {
-                "anyOf": [
-                  {
-                    "type": "string",
-                    "enum": [
-                      "session.list",
-                      "session.new",
-                      "session.share",
-                      "session.interrupt",
-                      "session.compact",
-                      "session.page.up",
-                      "session.page.down",
-                      "session.line.up",
-                      "session.line.down",
-                      "session.half.page.up",
-                      "session.half.page.down",
-                      "session.first",
-                      "session.last",
-                      "prompt.clear",
-                      "prompt.submit",
-                      "agent.cycle"
-                    ]
-                  },
-                  {
-                    "type": "string"
-                  }
-                ]
-              }
-            },
-            "required": ["command"],
-            "additionalProperties": false
-          }
-        },
-        "required": ["id", "type", "properties"],
-        "additionalProperties": false
-      },
-      "Event.tui.toast.show": {
-        "type": "object",
-        "properties": {
-          "id": {
-            "type": "string"
-          },
-          "type": {
-            "type": "string",
-            "enum": ["tui.toast.show"]
-          },
-          "properties": {
-            "type": "object",
-            "properties": {
-              "title": {
-                "type": "string"
-              },
-              "message": {
-                "type": "string"
-              },
-              "variant": {
-                "type": "string",
-                "enum": ["info", "success", "warning", "error"]
-              },
-              "duration": {
-                "type": "integer",
-                "exclusiveMinimum": 0
-              }
-            },
-            "required": ["message", "variant"],
-            "additionalProperties": false
-          }
-        },
-        "required": ["id", "type", "properties"],
-        "additionalProperties": false
-      },
-      "Event.tui.session.select": {
-        "type": "object",
-        "properties": {
-          "id": {
-            "type": "string"
-          },
-          "type": {
-            "type": "string",
-            "enum": ["tui.session.select"]
-          },
-          "properties": {
-            "type": "object",
-            "properties": {
-              "sessionID": {
-                "type": "string",
-                "pattern": "^ses",
-                "description": "Session ID to navigate to"
-              }
-            },
-            "required": ["sessionID"],
-            "additionalProperties": false
-          }
-        },
-        "required": ["id", "type", "properties"],
-        "additionalProperties": false
-      },
       "Project": {
         "type": "object",
         "properties": {
@@ -11129,6 +11129,24 @@
           },
           "payload": {
             "anyOf": [
+              {
+                "$ref": "#/components/schemas/Event.tui.prompt.append"
+              },
+              {
+                "$ref": "#/components/schemas/Event.tui.command.execute"
+              },
+              {
+                "$ref": "#/components/schemas/Event.tui.toast.show"
+              },
+              {
+                "$ref": "#/components/schemas/Event.tui.session.select"
+              },
+              {
+                "$ref": "#/components/schemas/EventServerConnected"
+              },
+              {
+                "$ref": "#/components/schemas/EventGlobalDisposed"
+              },
               {
                 "$ref": "#/components/schemas/EventServerInstanceDisposed"
               },
@@ -11186,18 +11204,6 @@
               {
                 "$ref": "#/components/schemas/EventSessionCompacted"
               },
-              {
-                "$ref": "#/components/schemas/Event.tui.prompt.append"
-              },
-              {
-                "$ref": "#/components/schemas/Event.tui.command.execute"
-              },
-              {
-                "$ref": "#/components/schemas/Event.tui.toast.show"
-              },
-              {
-                "$ref": "#/components/schemas/Event.tui.session.select"
-              },
               {
                 "$ref": "#/components/schemas/EventMcpToolsChanged"
               },
@@ -11339,12 +11345,6 @@
               {
                 "$ref": "#/components/schemas/EventSessionNextCompactionEnded"
               },
-              {
-                "$ref": "#/components/schemas/EventServerConnected"
-              },
-              {
-                "$ref": "#/components/schemas/EventGlobalDisposed"
-              },
               {
                 "$ref": "#/components/schemas/SyncEventMessageUpdated"
               },
@@ -15843,6 +15843,42 @@
         "required": ["type", "name", "id", "seq", "aggregateID", "data"],
         "additionalProperties": false
       },
+      "EventServerConnected": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string"
+          },
+          "type": {
+            "type": "string",
+            "enum": ["server.connected"]
+          },
+          "properties": {
+            "type": "object",
+            "properties": {}
+          }
+        },
+        "required": ["id", "type", "properties"],
+        "additionalProperties": false
+      },
+      "EventGlobalDisposed": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string"
+          },
+          "type": {
+            "type": "string",
+            "enum": ["global.disposed"]
+          },
+          "properties": {
+            "type": "object",
+            "properties": {}
+          }
+        },
+        "required": ["id", "type", "properties"],
+        "additionalProperties": false
+      },
       "EventServerInstanceDisposed": {
         "type": "object",
         "properties": {
@@ -18011,42 +18047,6 @@
         "required": ["id", "type", "properties"],
         "additionalProperties": false
       },
-      "EventServerConnected": {
-        "type": "object",
-        "properties": {
-          "id": {
-            "type": "string"
-          },
-          "type": {
-            "type": "string",
-            "enum": ["server.connected"]
-          },
-          "properties": {
-            "type": "object",
-            "properties": {}
-          }
-        },
-        "required": ["id", "type", "properties"],
-        "additionalProperties": false
-      },
-      "EventGlobalDisposed": {
-        "type": "object",
-        "properties": {
-          "id": {
-            "type": "string"
-          },
-          "type": {
-            "type": "string",
-            "enum": ["global.disposed"]
-          },
-          "properties": {
-            "type": "object",
-            "properties": {}
-          }
-        },
-        "required": ["id", "type", "properties"],
-        "additionalProperties": false
-      },
       "SessionInfo": {
         "type": "object",
         "properties": {
@@ -18725,19 +18725,24 @@
       },
       "BadRequestError": {
         "type": "object",
-        "required": ["data", "errors", "success"],
+        "required": ["name", "data"],
         "properties": {
-          "data": {},
-          "errors": {
-            "type": "array",
-            "items": {
-              "type": "object",
-              "additionalProperties": {}
-            }
+          "name": {
+            "type": "string",
+            "enum": ["BadRequest"]
           },
-          "success": {
-            "type": "boolean",
-            "enum": [false]
+          "data": {
+            "type": "object",
+            "required": ["message"],
+            "properties": {
+              "message": {
+                "type": "string"
+              },
+              "kind": {
+                "type": "string",
+                "enum": ["Params", "Headers", "Query", "Body", "Payload"]
+              }
+            }
           }
         }
       }