Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions packages/opencode/src/acp-next/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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 changes: 93 additions & 0 deletions packages/opencode/src/acp-next/error.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
27 changes: 7 additions & 20 deletions packages/opencode/src/acp-next/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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 {}
})
Expand All @@ -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 changes: 71 additions & 0 deletions packages/opencode/test/acp-next/error.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading