From 87e40f4d492d9206a4b98d2f755f65670abb2eed Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 25 May 2026 20:43:45 +0530 Subject: [PATCH] fix(acp-next): map errors to request errors --- packages/opencode/src/acp-next/agent.ts | 15 +-- packages/opencode/src/acp-next/error.ts | 93 +++++++++++++++++++ packages/opencode/src/acp-next/service.ts | 27 ++---- packages/opencode/test/acp-next/error.test.ts | 71 ++++++++++++++ 4 files changed, 176 insertions(+), 30 deletions(-) create mode 100644 packages/opencode/src/acp-next/error.ts create mode 100644 packages/opencode/test/acp-next/error.test.ts diff --git a/packages/opencode/src/acp-next/agent.ts b/packages/opencode/src/acp-next/agent.ts index b656b6331f65..4290117f585d 100644 --- a/packages/opencode/src/acp-next/agent.ts +++ b/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(effect: Effect.Effect) { - 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" diff --git a/packages/opencode/src/acp-next/error.ts b/packages/opencode/src/acp-next/error.ts new file mode 100644 index 000000000000..1d4af53b5030 --- /dev/null +++ b/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()( + "ACPNextSessionNotFoundError", + { + sessionId: Schema.String, + }, +) {} + +export class InvalidConfigOptionError extends Schema.TaggedErrorClass()( + "ACPNextInvalidConfigOptionError", + { + configId: Schema.String, + }, +) {} + +export class InvalidModelError extends Schema.TaggedErrorClass()("ACPNextInvalidModelError", { + modelId: Schema.String, + providerId: Schema.optional(Schema.String), +}) {} + +export class InvalidEffortError extends Schema.TaggedErrorClass()("ACPNextInvalidEffortError", { + effort: Schema.String, +}) {} + +export class InvalidModeError extends Schema.TaggedErrorClass()("ACPNextInvalidModeError", { + mode: Schema.String, +}) {} + +export class AuthRequiredError extends Schema.TaggedErrorClass()("ACPNextAuthRequiredError", { + providerId: Schema.optional(Schema.String), +}) {} + +export class UnknownAuthMethodError extends Schema.TaggedErrorClass()( + "ACPNextUnknownAuthMethodError", + { + methodId: Schema.String, + }, +) {} + +export class UnsupportedOperationError extends Schema.TaggedErrorClass()( + "ACPNextUnsupportedOperationError", + { + method: Schema.String, + }, +) {} + +export class ServiceFailureError extends Schema.TaggedErrorClass()("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 }) +} diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index b90c9c17547e..8ee1a8bd292c 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/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()( - "ACPNextUnknownAuthMethodError", - { - methodId: Schema.String, - }, -) {} - -export class UnsupportedOperationError extends Schema.TaggedErrorClass()( - "ACPNextUnsupportedOperationError", - { - method: Schema.String, - }, -) {} - -export type Error = UnknownAuthMethodError | UnsupportedOperationError +export type Error = ACPNextError.Error export type Interface = { readonly initialize: (input: InitializeRequest) => Effect.Effect @@ -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" }) }), } } diff --git a/packages/opencode/test/acp-next/error.test.ts b/packages/opencode/test/acp-next/error.test.ts new file mode 100644 index 000000000000..a82c6c576e11 --- /dev/null +++ b/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") + }) +})