fix(acp-next): map typed errors to request errors (#29233)
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "@agentclientprotocol/sdk"
|
} from "@agentclientprotocol/sdk"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
|
import * as ACPNextError from "./error"
|
||||||
import * as ACPNextService from "./service"
|
import * as ACPNextService from "./service"
|
||||||
|
|
||||||
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
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>) {
|
function run<A>(effect: Effect.Effect<A, ACPNextService.Error>) {
|
||||||
return Effect.runPromise(effect.pipe(Effect.mapError(toRequestError)))
|
return Effect.runPromise(effect.pipe(Effect.mapError(ACPNextError.toRequestError))).catch((defect: unknown) => {
|
||||||
}
|
if (defect instanceof RequestError) throw defect
|
||||||
|
throw ACPNextError.toRequestError(ACPNextError.fromUnknownDefect(defect))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as ACPNext from "./agent"
|
export * as ACPNext from "./agent"
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -11,25 +11,12 @@ import {
|
|||||||
type PromptResponse,
|
type PromptResponse,
|
||||||
} from "@agentclientprotocol/sdk"
|
} from "@agentclientprotocol/sdk"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
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 const AuthMethodID = "opencode-login"
|
||||||
|
|
||||||
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
|
export type Error = ACPNextError.Error
|
||||||
"ACPNextUnknownAuthMethodError",
|
|
||||||
{
|
|
||||||
methodId: Schema.String,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
|
|
||||||
"ACPNextUnsupportedOperationError",
|
|
||||||
{
|
|
||||||
method: Schema.String,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export type Error = UnknownAuthMethodError | UnsupportedOperationError
|
|
||||||
|
|
||||||
export type Interface = {
|
export type Interface = {
|
||||||
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
|
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) {
|
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
|
||||||
if (params.methodId !== AuthMethodID) {
|
if (params.methodId !== AuthMethodID) {
|
||||||
return yield* new UnknownAuthMethodError({ methodId: params.methodId })
|
return yield* new ACPNextError.UnknownAuthMethodError({ methodId: params.methodId })
|
||||||
}
|
}
|
||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
@@ -90,13 +77,13 @@ export function make(): Interface {
|
|||||||
initialize,
|
initialize,
|
||||||
authenticate,
|
authenticate,
|
||||||
newSession: Effect.fn("ACPNext.newSession")(function* (_input: NewSessionRequest) {
|
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) {
|
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) {
|
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
|
||||||
return yield* new UnsupportedOperationError({ method: "session/cancel" })
|
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user