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

fix(acp-next): map typed errors to request errors (#29233)

Shoubhit Dash 2 месяцев назад
Родитель
Сommit
7a5a997173

+ 5 - 10
packages/opencode/src/acp-next/agent.ts

@@ -10,6 +10,7 @@ import {
 } from "@agentclientprotocol/sdk"
 import { Effect } from "effect"
 import type { OpencodeClient } from "@opencode-ai/sdk/v2"
+import * as ACPNextError from "./error"
 import * as ACPNextService from "./service"
 
 export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
@@ -45,16 +46,10 @@ export class Agent implements ACPAgent {
 }
 
 function run<A>(effect: Effect.Effect<A, ACPNextService.Error>) {
-  return Effect.runPromise(effect.pipe(Effect.mapError(toRequestError)))
-}
-
-function toRequestError(error: ACPNextService.Error) {
-  switch (error._tag) {
-    case "ACPNextUnknownAuthMethodError":
-      return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
-    case "ACPNextUnsupportedOperationError":
-      return RequestError.methodNotFound(error.method)
-  }
+  return Effect.runPromise(effect.pipe(Effect.mapError(ACPNextError.toRequestError))).catch((defect: unknown) => {
+    if (defect instanceof RequestError) throw defect
+    throw ACPNextError.toRequestError(ACPNextError.fromUnknownDefect(defect))
+  })
 }
 
 export * as ACPNext from "./agent"

+ 93 - 0
packages/opencode/src/acp-next/error.ts

@@ -0,0 +1,93 @@
+import { RequestError } from "@agentclientprotocol/sdk"
+import { Schema } from "effect"
+
+export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
+  "ACPNextSessionNotFoundError",
+  {
+    sessionId: Schema.String,
+  },
+) {}
+
+export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
+  "ACPNextInvalidConfigOptionError",
+  {
+    configId: Schema.String,
+  },
+) {}
+
+export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPNextInvalidModelError", {
+  modelId: Schema.String,
+  providerId: Schema.optional(Schema.String),
+}) {}
+
+export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPNextInvalidEffortError", {
+  effort: Schema.String,
+}) {}
+
+export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPNextInvalidModeError", {
+  mode: Schema.String,
+}) {}
+
+export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPNextAuthRequiredError", {
+  providerId: Schema.optional(Schema.String),
+}) {}
+
+export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
+  "ACPNextUnknownAuthMethodError",
+  {
+    methodId: Schema.String,
+  },
+) {}
+
+export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
+  "ACPNextUnsupportedOperationError",
+  {
+    method: Schema.String,
+  },
+) {}
+
+export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPNextServiceFailureError", {
+  safeMessage: Schema.String,
+  service: Schema.optional(Schema.String),
+}) {}
+
+export type Error =
+  | SessionNotFoundError
+  | InvalidConfigOptionError
+  | InvalidModelError
+  | InvalidEffortError
+  | InvalidModeError
+  | AuthRequiredError
+  | UnknownAuthMethodError
+  | UnsupportedOperationError
+  | ServiceFailureError
+
+export function toRequestError(error: Error) {
+  switch (error._tag) {
+    case "ACPNextSessionNotFoundError":
+      return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
+    case "ACPNextInvalidConfigOptionError":
+      return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
+    case "ACPNextInvalidModelError":
+      return RequestError.invalidParams(
+        { providerId: error.providerId, modelId: error.modelId },
+        `model not found: ${error.modelId}`,
+      )
+    case "ACPNextInvalidEffortError":
+      return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
+    case "ACPNextInvalidModeError":
+      return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
+    case "ACPNextAuthRequiredError":
+      return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required")
+    case "ACPNextUnknownAuthMethodError":
+      return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
+    case "ACPNextUnsupportedOperationError":
+      return RequestError.methodNotFound(error.method)
+    case "ACPNextServiceFailureError":
+      return RequestError.internalError({ service: error.service }, error.safeMessage)
+  }
+}
+
+export function fromUnknownDefect(_defect: unknown, safeMessage = "Internal service failure") {
+  return new ServiceFailureError({ safeMessage })
+}

+ 7 - 20
packages/opencode/src/acp-next/service.ts

@@ -11,25 +11,12 @@ import {
   type PromptResponse,
 } from "@agentclientprotocol/sdk"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
-import { Context, Effect, Schema } from "effect"
+import { Context, Effect } from "effect"
+import * as ACPNextError from "./error"
 
 export const AuthMethodID = "opencode-login"
 
-export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
-  "ACPNextUnknownAuthMethodError",
-  {
-    methodId: Schema.String,
-  },
-) {}
-
-export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
-  "ACPNextUnsupportedOperationError",
-  {
-    method: Schema.String,
-  },
-) {}
-
-export type Error = UnknownAuthMethodError | UnsupportedOperationError
+export type Error = ACPNextError.Error
 
 export type Interface = {
   readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
@@ -81,7 +68,7 @@ export function make(): Interface {
 
   const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
     if (params.methodId !== AuthMethodID) {
-      return yield* new UnknownAuthMethodError({ methodId: params.methodId })
+      return yield* new ACPNextError.UnknownAuthMethodError({ methodId: params.methodId })
     }
     return {}
   })
@@ -90,13 +77,13 @@ export function make(): Interface {
     initialize,
     authenticate,
     newSession: Effect.fn("ACPNext.newSession")(function* (_input: NewSessionRequest) {
-      return yield* new UnsupportedOperationError({ method: "session/new" })
+      return yield* new ACPNextError.UnsupportedOperationError({ method: "session/new" })
     }),
     prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
-      return yield* new UnsupportedOperationError({ method: "session/prompt" })
+      return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
     }),
     cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
-      return yield* new UnsupportedOperationError({ method: "session/cancel" })
+      return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
     }),
   }
 }

+ 71 - 0
packages/opencode/test/acp-next/error.test.ts

@@ -0,0 +1,71 @@
+import { describe, expect, test } from "bun:test"
+import { RequestError } from "@agentclientprotocol/sdk"
+import * as ACPNextError from "../../src/acp-next/error"
+
+describe("acp-next.error", () => {
+  test("maps validation failures to invalid params", () => {
+    const cases: ACPNextError.Error[] = [
+      new ACPNextError.SessionNotFoundError({ sessionId: "ses_missing" }),
+      new ACPNextError.InvalidConfigOptionError({ configId: "temperature" }),
+      new ACPNextError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
+      new ACPNextError.InvalidEffortError({ effort: "extreme" }),
+      new ACPNextError.InvalidModeError({ mode: "turbo" }),
+    ]
+
+    expect(cases.map((error) => ACPNextError.toRequestError(error).code)).toEqual([
+      -32602, -32602, -32602, -32602, -32602,
+    ])
+  })
+
+  test("includes safe validation details", () => {
+    expect(ACPNextError.toRequestError(new ACPNextError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
+      code: -32602,
+      data: { sessionId: "ses_123" },
+    })
+    expect(ACPNextError.toRequestError(new ACPNextError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
+      code: -32602,
+      data: { modelId: "gpt-missing" },
+    })
+  })
+
+  test("maps auth required to the SDK auth error", () => {
+    const requestError = ACPNextError.toRequestError(new ACPNextError.AuthRequiredError({ providerId: "anthropic" }))
+
+    expect(requestError).toBeInstanceOf(RequestError)
+    expect(requestError.code).toBe(-32000)
+    expect(requestError.message).toBe("Authentication required: provider authentication required")
+    expect(requestError.data).toEqual({ providerId: "anthropic" })
+  })
+
+  test("maps unsupported operations to method not found", () => {
+    const requestError = ACPNextError.toRequestError(
+      new ACPNextError.UnsupportedOperationError({ method: "session/new" }),
+    )
+
+    expect(requestError.code).toBe(-32601)
+    expect(requestError.data).toEqual({ method: "session/new" })
+  })
+
+  test("maps service failures to safe internal errors", () => {
+    const requestError = ACPNextError.toRequestError(
+      new ACPNextError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
+    )
+
+    expect(requestError.code).toBe(-32603)
+    expect(requestError.message).toBe("Internal error: Provider request failed")
+    expect(requestError.data).toEqual({ service: "provider" })
+  })
+
+  test("wraps unknown defects without leaking raw details", () => {
+    const requestError = ACPNextError.toRequestError(
+      ACPNextError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")),
+    )
+    const serialized = JSON.stringify(requestError.toErrorResponse())
+
+    expect(requestError.code).toBe(-32603)
+    expect(requestError.message).toBe("Internal error: Internal service failure")
+    expect(serialized).not.toContain("sk-ant-secret")
+    expect(serialized).not.toContain("oauth refresh token")
+    expect(serialized).not.toContain("stack")
+  })
+})