feat(acp): implement acp-next session slice (#29250)
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
type AuthenticateRequest,
|
type AuthenticateRequest,
|
||||||
type CancelNotification,
|
type CancelNotification,
|
||||||
type InitializeRequest,
|
type InitializeRequest,
|
||||||
|
type LoadSessionRequest,
|
||||||
type NewSessionRequest,
|
type NewSessionRequest,
|
||||||
type PromptRequest,
|
type PromptRequest,
|
||||||
} from "@agentclientprotocol/sdk"
|
} from "@agentclientprotocol/sdk"
|
||||||
@@ -15,8 +16,8 @@ import * as ACPNextService from "./service"
|
|||||||
|
|
||||||
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
||||||
return {
|
return {
|
||||||
create: (_connection: AgentSideConnection) => {
|
create: (connection: AgentSideConnection) => {
|
||||||
return new Agent(ACPNextService.make())
|
return new Agent(ACPNextService.make({ sdk: _sdk, connection }))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,10 @@ export class Agent implements ACPAgent {
|
|||||||
return run(this.service.newSession(params))
|
return run(this.service.newSession(params))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadSession(params: LoadSessionRequest) {
|
||||||
|
return run(this.service.loadSession(params))
|
||||||
|
}
|
||||||
|
|
||||||
prompt(params: PromptRequest) {
|
prompt(params: PromptRequest) {
|
||||||
return run(this.service.prompt(params))
|
return run(this.service.prompt(params))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import {
|
import {
|
||||||
|
type AgentSideConnection,
|
||||||
type AuthenticateRequest,
|
type AuthenticateRequest,
|
||||||
type AuthenticateResponse,
|
type AuthenticateResponse,
|
||||||
type AuthMethod,
|
type AuthMethod,
|
||||||
type CancelNotification,
|
type CancelNotification,
|
||||||
type InitializeRequest,
|
type InitializeRequest,
|
||||||
type InitializeResponse,
|
type InitializeResponse,
|
||||||
|
type LoadSessionRequest,
|
||||||
|
type LoadSessionResponse,
|
||||||
|
type McpServer,
|
||||||
type NewSessionRequest,
|
type NewSessionRequest,
|
||||||
type NewSessionResponse,
|
type NewSessionResponse,
|
||||||
type PromptRequest,
|
type PromptRequest,
|
||||||
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 type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { Context, Effect } from "effect"
|
import { Context, Effect } from "effect"
|
||||||
import * as ACPNextError from "./error"
|
import * as ACPNextError from "./error"
|
||||||
|
import { buildConfigOptions } from "./config-option"
|
||||||
|
import { Directory } from "./directory"
|
||||||
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
|
import { Provider } from "@/provider/provider"
|
||||||
|
import type { Command } from "@/command"
|
||||||
|
|
||||||
export const AuthMethodID = "opencode-login"
|
export const AuthMethodID = "opencode-login"
|
||||||
|
|
||||||
@@ -22,13 +32,18 @@ export type Interface = {
|
|||||||
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
|
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
|
||||||
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Error>
|
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Error>
|
||||||
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error>
|
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error>
|
||||||
|
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Error>
|
||||||
readonly prompt: (input: PromptRequest) => Effect.Effect<PromptResponse, Error>
|
readonly prompt: (input: PromptRequest) => Effect.Effect<PromptResponse, Error>
|
||||||
readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error>
|
readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Service") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Service") {}
|
||||||
|
|
||||||
export function make(): Interface {
|
export function make(input: { sdk: OpencodeClient; connection?: Pick<AgentSideConnection, "sessionUpdate"> }): Interface {
|
||||||
|
const sessions = new Map<string, SessionState>()
|
||||||
|
const directories = new Map<string, Promise<Directory.Snapshot>>()
|
||||||
|
const registeredMcp = new Map<string, Set<string>>()
|
||||||
|
|
||||||
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
|
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
|
||||||
const authMethod: AuthMethod = {
|
const authMethod: AuthMethod = {
|
||||||
description: "Run `opencode auth login` in the terminal",
|
description: "Run `opencode auth login` in the terminal",
|
||||||
@@ -49,6 +64,7 @@ export function make(): Interface {
|
|||||||
return {
|
return {
|
||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
agentCapabilities: {
|
agentCapabilities: {
|
||||||
|
loadSession: true,
|
||||||
mcpCapabilities: {
|
mcpCapabilities: {
|
||||||
http: true,
|
http: true,
|
||||||
sse: true,
|
sse: true,
|
||||||
@@ -73,12 +89,96 @@ export function make(): Interface {
|
|||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (directory: string) {
|
||||||
|
const cached = directories.get(directory)
|
||||||
|
if (cached) return yield* request(() => cached, "directory")
|
||||||
|
|
||||||
|
const promise = loadDirectorySnapshot(input.sdk, directory).catch((error: unknown) => {
|
||||||
|
directories.delete(directory)
|
||||||
|
throw fromUnknownError(error, "directory")
|
||||||
|
})
|
||||||
|
directories.set(directory, promise)
|
||||||
|
return yield* request(() => promise, "directory")
|
||||||
|
})
|
||||||
|
|
||||||
|
const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
|
||||||
|
const snapshot = yield* directorySnapshot(params.cwd)
|
||||||
|
const selected = selectDefaultModel(snapshot)
|
||||||
|
const variant = selectVariant(snapshot, selected)
|
||||||
|
const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined
|
||||||
|
const created = yield* request(
|
||||||
|
() =>
|
||||||
|
input.sdk.session.create(
|
||||||
|
{
|
||||||
|
directory: params.cwd,
|
||||||
|
...(modeId ? { agent: modeId } : {}),
|
||||||
|
model: {
|
||||||
|
providerID: selected.providerID,
|
||||||
|
id: selected.modelID,
|
||||||
|
...(variant ? { variant } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ throwOnError: true },
|
||||||
|
),
|
||||||
|
"session",
|
||||||
|
)
|
||||||
|
const state = storeSession(sessions, {
|
||||||
|
id: created.id,
|
||||||
|
cwd: params.cwd,
|
||||||
|
mcpServers: params.mcpServers,
|
||||||
|
model: selected,
|
||||||
|
variant,
|
||||||
|
modeId,
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, params.mcpServers)
|
||||||
|
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: state.id,
|
||||||
|
configOptions: configOptions(snapshot, state),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
|
||||||
|
const snapshot = yield* directorySnapshot(params.cwd)
|
||||||
|
yield* request(
|
||||||
|
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }),
|
||||||
|
"session",
|
||||||
|
)
|
||||||
|
const messages = yield* request(
|
||||||
|
() =>
|
||||||
|
input.sdk.session.messages(
|
||||||
|
{ directory: params.cwd, sessionID: params.sessionId, limit: 100 },
|
||||||
|
{ throwOnError: true },
|
||||||
|
),
|
||||||
|
"session",
|
||||||
|
)
|
||||||
|
const restored = restoreFromMessages(messages.map((item) => item.info))
|
||||||
|
const model = restored.model ?? selectDefaultModel(snapshot)
|
||||||
|
const state = storeSession(sessions, {
|
||||||
|
id: params.sessionId,
|
||||||
|
cwd: params.cwd,
|
||||||
|
mcpServers: params.mcpServers,
|
||||||
|
model,
|
||||||
|
variant: restored.variant ?? selectVariant(snapshot, model),
|
||||||
|
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, params.mcpServers)
|
||||||
|
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: state.id,
|
||||||
|
configOptions: configOptions(snapshot, state),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialize,
|
initialize,
|
||||||
authenticate,
|
authenticate,
|
||||||
newSession: Effect.fn("ACPNext.newSession")(function* (_input: NewSessionRequest) {
|
newSession,
|
||||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/new" })
|
loadSession,
|
||||||
}),
|
|
||||||
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
|
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
|
||||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
|
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
|
||||||
}),
|
}),
|
||||||
@@ -87,3 +187,307 @@ export function make(): Interface {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionState = {
|
||||||
|
readonly id: string
|
||||||
|
readonly cwd: string
|
||||||
|
readonly mcpServers: readonly McpServer[]
|
||||||
|
readonly model: Directory.DefaultModel
|
||||||
|
readonly variant?: string
|
||||||
|
readonly modeId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SdkResponse<T> = {
|
||||||
|
readonly data?: T
|
||||||
|
readonly error?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageInfo = {
|
||||||
|
readonly role?: string
|
||||||
|
readonly model?: {
|
||||||
|
readonly providerID?: string
|
||||||
|
readonly modelID?: string
|
||||||
|
readonly variant?: string
|
||||||
|
}
|
||||||
|
readonly providerID?: string
|
||||||
|
readonly modelID?: string
|
||||||
|
readonly variant?: string
|
||||||
|
readonly mode?: string
|
||||||
|
readonly agent?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: async () => {
|
||||||
|
const result = await fn()
|
||||||
|
if (isSdkResponse<T>(result)) {
|
||||||
|
if (result.error) throw result.error
|
||||||
|
if (result.data !== undefined) return result.data
|
||||||
|
}
|
||||||
|
return result as T
|
||||||
|
},
|
||||||
|
catch: (error) => fromUnknownError(error, service),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
|
||||||
|
const [providersResponse, agentsResponse, commandsResponse, skillsResponse] = await Promise.all([
|
||||||
|
sdk.config.providers({ directory }, { throwOnError: true }),
|
||||||
|
sdk.app.agents({ directory }, { throwOnError: true }),
|
||||||
|
sdk.command.list({ directory }, { throwOnError: true }),
|
||||||
|
sdk.app.skills({ directory }, { throwOnError: true }),
|
||||||
|
])
|
||||||
|
const providersData = providersResponse.data!
|
||||||
|
const agents = agentsResponse.data!
|
||||||
|
const commandsData = commandsResponse.data!
|
||||||
|
const skills = skillsResponse.data!
|
||||||
|
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
|
||||||
|
ProviderID,
|
||||||
|
Provider.Info
|
||||||
|
>
|
||||||
|
const defaultModel = await defaultModelFromSdk(sdk, directory, providers)
|
||||||
|
const modes = agents
|
||||||
|
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
|
||||||
|
.map((agent) => ({
|
||||||
|
id: agent.name,
|
||||||
|
name: agent.name,
|
||||||
|
...(agent.description ? { description: agent.description } : {}),
|
||||||
|
}))
|
||||||
|
const commands = [
|
||||||
|
...commandsData,
|
||||||
|
...skills
|
||||||
|
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
|
||||||
|
.map((skill) => ({
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
source: "skill" as const,
|
||||||
|
template: skill.content,
|
||||||
|
hints: [],
|
||||||
|
})),
|
||||||
|
] as Command.Info[]
|
||||||
|
|
||||||
|
return Directory.build({
|
||||||
|
directory,
|
||||||
|
providers,
|
||||||
|
modes,
|
||||||
|
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
|
||||||
|
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
...(defaultModel ? { defaultModel } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultModelFromSdk(
|
||||||
|
sdk: OpencodeClient,
|
||||||
|
directory: string,
|
||||||
|
providers: Record<ProviderID, Provider.Info>,
|
||||||
|
): Promise<Directory.DefaultModel | undefined> {
|
||||||
|
const configured = await sdk.config
|
||||||
|
.get({ directory }, { throwOnError: true })
|
||||||
|
.then((response) => (response.data?.model ? Provider.parseModel(response.data.model) : undefined))
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
|
||||||
|
|
||||||
|
const lastUsed = await lastUsedModel(sdk, directory, providers)
|
||||||
|
if (lastUsed) return lastUsed
|
||||||
|
|
||||||
|
const opencodeProvider = providers[ProviderID.make("opencode")]
|
||||||
|
const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
|
||||||
|
if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }
|
||||||
|
|
||||||
|
const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0]
|
||||||
|
if (best) return { providerID: best.providerID, modelID: best.id }
|
||||||
|
if (configured) return configured
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lastUsedModel(
|
||||||
|
sdk: OpencodeClient,
|
||||||
|
directory: string,
|
||||||
|
providers: Record<ProviderID, Provider.Info>,
|
||||||
|
): Promise<Directory.DefaultModel | undefined> {
|
||||||
|
const session = await sdk.session
|
||||||
|
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
|
||||||
|
.then((response) => response.data?.[0])
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const lastUser = await sdk.session
|
||||||
|
.messages({ directory, sessionID: session.id, limit: 20 }, { throwOnError: true })
|
||||||
|
.then((response) => response.data?.findLast((message) => message.info.role === "user")?.info)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (lastUser?.role !== "user") return
|
||||||
|
if (!providers[ProviderID.make(lastUser.model.providerID)]?.models[ModelID.make(lastUser.model.modelID)]) return
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerID: ProviderID.make(lastUser.model.providerID),
|
||||||
|
modelID: ModelID.make(lastUser.model.modelID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDefaultModel(snapshot: Directory.Snapshot) {
|
||||||
|
if (snapshot.defaultModel) return snapshot.defaultModel
|
||||||
|
const model = snapshot.modelOptions[0]
|
||||||
|
if (model) return { providerID: model.providerID, modelID: model.modelID }
|
||||||
|
return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID }
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
|
||||||
|
const variants = Directory.variants(snapshot, model)
|
||||||
|
if (!variants) return
|
||||||
|
if (variants.default) return "default"
|
||||||
|
return Object.keys(variants)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeSession(sessions: Map<string, SessionState>, state: SessionState) {
|
||||||
|
sessions.set(state.id, {
|
||||||
|
...state,
|
||||||
|
mcpServers: [...state.mcpServers],
|
||||||
|
})
|
||||||
|
return sessions.get(state.id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
function configOptions(snapshot: Directory.Snapshot, session: SessionState) {
|
||||||
|
return buildConfigOptions({
|
||||||
|
providers: Object.values(snapshot.providers),
|
||||||
|
currentModel: session.model,
|
||||||
|
currentVariant: session.variant,
|
||||||
|
modes: snapshot.availableModes,
|
||||||
|
currentModeId: session.modeId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAvailableCommands(
|
||||||
|
connection: Pick<AgentSideConnection, "sessionUpdate"> | undefined,
|
||||||
|
sessionId: string,
|
||||||
|
snapshot: Directory.Snapshot,
|
||||||
|
) {
|
||||||
|
if (!connection) return Effect.void
|
||||||
|
return Effect.sync(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
void connection.sessionUpdate({
|
||||||
|
sessionId,
|
||||||
|
update: {
|
||||||
|
sessionUpdate: "available_commands_update",
|
||||||
|
availableCommands: snapshot.availableCommands.map((command) => ({
|
||||||
|
name: command.name,
|
||||||
|
description: command.description ?? "",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerMcpServers(
|
||||||
|
sdk: OpencodeClient,
|
||||||
|
registered: Map<string, Set<string>>,
|
||||||
|
directory: string,
|
||||||
|
servers: readonly McpServer[],
|
||||||
|
) {
|
||||||
|
const current = registered.get(directory) ?? new Set<string>()
|
||||||
|
registered.set(directory, current)
|
||||||
|
|
||||||
|
return Effect.all(
|
||||||
|
Array.from(new Map(servers.map((server) => [server.name, server])).values())
|
||||||
|
.filter((server) => !current.has(server.name))
|
||||||
|
.map((server) =>
|
||||||
|
request(
|
||||||
|
() =>
|
||||||
|
sdk.mcp.add(
|
||||||
|
{
|
||||||
|
directory,
|
||||||
|
name: server.name,
|
||||||
|
config: mcpConfig(server),
|
||||||
|
},
|
||||||
|
{ throwOnError: true },
|
||||||
|
),
|
||||||
|
"mcp",
|
||||||
|
).pipe(Effect.tap(() => Effect.sync(() => current.add(server.name))), Effect.ignore),
|
||||||
|
),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
).pipe(Effect.asVoid)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mcpConfig(server: McpServer) {
|
||||||
|
if ("type" in server) {
|
||||||
|
return {
|
||||||
|
type: "remote" as const,
|
||||||
|
url: server.url,
|
||||||
|
headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "local" as const,
|
||||||
|
command: [server.command, ...server.args],
|
||||||
|
environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFromMessages(messages: readonly MessageInfo[]) {
|
||||||
|
const user = messages.findLast(
|
||||||
|
(message) => message.role === "user" && message.model?.providerID && message.model.modelID,
|
||||||
|
)
|
||||||
|
if (user?.model?.providerID && user.model.modelID) {
|
||||||
|
return {
|
||||||
|
model: { providerID: user.model.providerID as ProviderID, modelID: user.model.modelID as ModelID },
|
||||||
|
variant: user.model.variant,
|
||||||
|
modeId: user.agent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = messages.findLast((message) => message.providerID && message.modelID)
|
||||||
|
if (assistant?.providerID && assistant.modelID) {
|
||||||
|
return {
|
||||||
|
model: { providerID: assistant.providerID as ProviderID, modelID: assistant.modelID as ModelID },
|
||||||
|
variant: assistant.variant,
|
||||||
|
modeId: assistant.mode ?? assistant.agent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSdkResponse<T>(value: T | SdkResponse<T>): value is SdkResponse<T> {
|
||||||
|
return typeof value === "object" && value !== null && ("data" in value || "error" in value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromUnknownError(error: unknown, service?: string): Error {
|
||||||
|
if (isACPNextError(error)) return error
|
||||||
|
if (isAuthRequired(error)) {
|
||||||
|
return new ACPNextError.AuthRequiredError({ providerId: findProviderID(error) })
|
||||||
|
}
|
||||||
|
return new ACPNextError.ServiceFailureError({ safeMessage: "OpenCode service failure", service })
|
||||||
|
}
|
||||||
|
|
||||||
|
function isACPNextError(error: unknown): error is Error {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"_tag" in error &&
|
||||||
|
typeof error._tag === "string" &&
|
||||||
|
error._tag.startsWith("ACPNext")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthRequired(value: unknown): boolean {
|
||||||
|
if (typeof value !== "object" || value === null) return false
|
||||||
|
if (value instanceof Error && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true
|
||||||
|
if (
|
||||||
|
value instanceof Error &&
|
||||||
|
(value.message.includes("ProviderAuthError") || value.message.includes("LoadAPIKeyError"))
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ("name" in value && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true
|
||||||
|
if ("_tag" in value && (value._tag === "ProviderAuthError" || value._tag === "LoadAPIKeyError")) return true
|
||||||
|
if ("error" in value && isAuthRequired(value.error)) return true
|
||||||
|
if ("data" in value && isAuthRequired(value.data)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function findProviderID(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "object" || value === null) return
|
||||||
|
if ("providerID" in value && typeof value.providerID === "string") return value.providerID
|
||||||
|
if ("providerId" in value && typeof value.providerId === "string") return value.providerId
|
||||||
|
if ("data" in value) return findProviderID(value.data)
|
||||||
|
if ("error" in value) return findProviderID(value.error)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import type { AgentSideConnection, LoadSessionResponse, NewSessionResponse } from "@agentclientprotocol/sdk"
|
||||||
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import * as ACPNextService from "@/acp-next/service"
|
||||||
|
import * as ACPNextError from "@/acp-next/error"
|
||||||
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
|
import type { Provider } from "@/provider/provider"
|
||||||
|
|
||||||
|
const providerID = ProviderID.make("test")
|
||||||
|
const modelID = ModelID.make("test-model")
|
||||||
|
const configuredModelID = ModelID.make("configured-model")
|
||||||
|
|
||||||
|
const provider: Provider.Info = {
|
||||||
|
id: providerID,
|
||||||
|
name: "Test",
|
||||||
|
source: "config",
|
||||||
|
env: [],
|
||||||
|
options: {},
|
||||||
|
models: {
|
||||||
|
[modelID]: {
|
||||||
|
id: modelID,
|
||||||
|
providerID,
|
||||||
|
api: {
|
||||||
|
id: modelID,
|
||||||
|
url: "https://example.com",
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
},
|
||||||
|
name: "Test Model",
|
||||||
|
family: "test",
|
||||||
|
capabilities: {
|
||||||
|
temperature: true,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: false,
|
||||||
|
toolcall: true,
|
||||||
|
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
interleaved: false,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cache: { read: 0, write: 0 },
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 128000,
|
||||||
|
output: 4096,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
release_date: "2026-01-01",
|
||||||
|
variants: {
|
||||||
|
default: {},
|
||||||
|
high: { reasoningEffort: "high" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[configuredModelID]: {
|
||||||
|
id: configuredModelID,
|
||||||
|
providerID,
|
||||||
|
api: {
|
||||||
|
id: configuredModelID,
|
||||||
|
url: "https://example.com",
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
},
|
||||||
|
name: "Configured Model",
|
||||||
|
family: "test",
|
||||||
|
capabilities: {
|
||||||
|
temperature: true,
|
||||||
|
reasoning: false,
|
||||||
|
attachment: false,
|
||||||
|
toolcall: true,
|
||||||
|
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
interleaved: false,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cache: { read: 0, write: 0 },
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 128000,
|
||||||
|
output: 4096,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
release_date: "2026-01-01",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ACP next service sessions", () => {
|
||||||
|
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
|
||||||
|
const updates: unknown[] = []
|
||||||
|
const mcpAdds: string[] = []
|
||||||
|
const sdk = {
|
||||||
|
config: {
|
||||||
|
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
|
||||||
|
get: () => Promise.resolve({ data: {} }),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
agents: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: [
|
||||||
|
{ name: "build", mode: "primary", permission: [], options: {} },
|
||||||
|
{ name: "plan", mode: "primary", description: "Plan first", permission: [], options: {} },
|
||||||
|
{ name: "hidden", mode: "primary", hidden: true, permission: [], options: {} },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
skills: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: [{ name: "review-skill", description: "Review", location: "/skills/review", content: "review" }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
list: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: [{ name: "init", description: "Initialize", source: "command", template: "init", hints: [] }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
create: () => Promise.resolve({ data: { id: "ses_new" } }),
|
||||||
|
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
|
||||||
|
list: () => Promise.resolve({ data: [] }),
|
||||||
|
messages: () => Promise.resolve({ data: messages }),
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
add: (input: { name?: string }) => {
|
||||||
|
if (input.name) mcpAdds.push(input.name)
|
||||||
|
return Promise.resolve({ data: {} })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient
|
||||||
|
const connection = {
|
||||||
|
sessionUpdate: (update: unknown) => {
|
||||||
|
updates.push(update)
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
} as Pick<AgentSideConnection, "sessionUpdate">
|
||||||
|
|
||||||
|
return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds }
|
||||||
|
}
|
||||||
|
|
||||||
|
it("creates a backed session with config options and command update", async () => {
|
||||||
|
const { service, updates, mcpAdds } = makeService()
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
service.newSession({
|
||||||
|
cwd: "/workspace",
|
||||||
|
mcpServers: [
|
||||||
|
{ name: "tools", command: "node", args: ["server.js"], env: [] },
|
||||||
|
{ name: "tools", command: "node", args: ["server.js"], env: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||||
|
|
||||||
|
expect(result.sessionId).toBe("ses_new")
|
||||||
|
expect(categories(result)).toContain("model")
|
||||||
|
expect(categories(result)).toContain("thought_level")
|
||||||
|
expect(categories(result)).toContain("mode")
|
||||||
|
expect(updates).toHaveLength(1)
|
||||||
|
expect(JSON.stringify(updates[0])).toContain("available_commands_update")
|
||||||
|
expect(JSON.stringify(updates[0])).toContain("review-skill")
|
||||||
|
expect(mcpAdds).toEqual(["tools"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("loads a session and restores model variant and mode from messages", async () => {
|
||||||
|
const { service } = makeService([
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
role: "assistant",
|
||||||
|
providerID: "test",
|
||||||
|
modelID: "test-model",
|
||||||
|
variant: "high",
|
||||||
|
mode: "plan",
|
||||||
|
},
|
||||||
|
parts: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
|
||||||
|
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("restores model variant and mode from the latest user message", async () => {
|
||||||
|
const { service } = makeService([
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
role: "user",
|
||||||
|
model: { providerID: "test", modelID: "test-model", variant: "default" },
|
||||||
|
agent: "build",
|
||||||
|
},
|
||||||
|
parts: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
role: "user",
|
||||||
|
model: { providerID: "test", modelID: "test-model", variant: "high" },
|
||||||
|
agent: "plan",
|
||||||
|
},
|
||||||
|
parts: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
|
||||||
|
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
it("maps provider auth failures to auth-required request errors", async () => {
|
||||||
|
const service = ACPNextService.make({
|
||||||
|
sdk: {
|
||||||
|
config: {
|
||||||
|
providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
|
||||||
|
get: () => Promise.resolve({ data: {} }),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
agents: () => Promise.resolve({ data: [] }),
|
||||||
|
skills: () => Promise.resolve({ data: [] }),
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
list: () => Promise.resolve({ data: [] }),
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient,
|
||||||
|
})
|
||||||
|
const error = await Effect.runPromise(
|
||||||
|
service
|
||||||
|
.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||||
|
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.code).toBe(-32000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not cache failed directory snapshots", async () => {
|
||||||
|
let providersCalls = 0
|
||||||
|
const sdk = {
|
||||||
|
config: {
|
||||||
|
providers: () => {
|
||||||
|
providersCalls++
|
||||||
|
if (providersCalls === 1) {
|
||||||
|
return Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
|
||||||
|
},
|
||||||
|
get: () => Promise.resolve({ data: {} }),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
|
||||||
|
skills: () => Promise.resolve({ data: [] }),
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
list: () => Promise.resolve({ data: [] }),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
create: () => Promise.resolve({ data: { id: "ses_retry" } }),
|
||||||
|
list: () => Promise.resolve({ data: [] }),
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
add: () => Promise.resolve({ data: {} }),
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient
|
||||||
|
const service = ACPNextService.make({ sdk })
|
||||||
|
|
||||||
|
const first = await Effect.runPromise(
|
||||||
|
service
|
||||||
|
.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||||
|
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||||
|
)
|
||||||
|
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||||
|
|
||||||
|
expect(first.code).toBe(-32000)
|
||||||
|
expect(second.sessionId).toBe("ses_retry")
|
||||||
|
expect(providersCalls).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses the configured model as the new session default", async () => {
|
||||||
|
const sdk = {
|
||||||
|
config: {
|
||||||
|
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
|
||||||
|
get: () => Promise.resolve({ data: { model: "test/configured-model" } }),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
|
||||||
|
skills: () => Promise.resolve({ data: [] }),
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
list: () => Promise.resolve({ data: [] }),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }),
|
||||||
|
list: () => Promise.resolve({ data: [] }),
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
add: () => Promise.resolve({ data: {} }),
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient
|
||||||
|
const service = ACPNextService.make({ sdk })
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||||
|
|
||||||
|
expect(result.sessionId).toBe("configured-model")
|
||||||
|
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function categories(result: NewSessionResponse | LoadSessionResponse) {
|
||||||
|
return result.configOptions?.map((option) => option.category) ?? []
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
|
import type {
|
||||||
|
AuthenticateResponse,
|
||||||
|
InitializeResponse,
|
||||||
|
LoadSessionResponse,
|
||||||
|
NewSessionResponse,
|
||||||
|
SessionNotification,
|
||||||
|
} from "@agentclientprotocol/sdk"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { cliIt } from "../../lib/cli-process"
|
import { cliIt } from "../../lib/cli-process"
|
||||||
import { createAcpClient, expectOk } from "../acp/acp-test-client"
|
import { testProviderConfig } from "../../lib/test-provider"
|
||||||
|
import { createAcpClient, expectOk, selectConfigOption } from "../acp/acp-test-client"
|
||||||
|
|
||||||
describe("opencode acp-next (subprocess)", () => {
|
describe("opencode acp-next (subprocess)", () => {
|
||||||
cliIt.live(
|
cliIt.live(
|
||||||
@@ -22,6 +29,7 @@ describe("opencode acp-next (subprocess)", () => {
|
|||||||
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
|
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
|
||||||
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
|
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
|
||||||
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
|
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
|
||||||
|
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
||||||
expect(initialized.agentCapabilities?.sessionCapabilities).toBeUndefined()
|
expect(initialized.agentCapabilities?.sessionCapabilities).toBeUndefined()
|
||||||
expect(initialized.agentInfo?.name).toBe("OpenCode")
|
expect(initialized.agentInfo?.name).toBe("OpenCode")
|
||||||
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
|
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
|
||||||
@@ -48,14 +56,41 @@ describe("opencode acp-next (subprocess)", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
cliIt.live(
|
cliIt.live(
|
||||||
"SDK-required session stubs fail with safe unsupported errors",
|
"creates and loads sessions behind OPENCODE_ACP_NEXT",
|
||||||
({ home, opencode }) =>
|
({ home, llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
|
const acp = createAcpClient(
|
||||||
|
yield* opencode.acp({
|
||||||
|
env: {
|
||||||
|
OPENCODE_ACP_NEXT: "1",
|
||||||
|
OPENCODE_CONFIG_CONTENT: JSON.stringify(testProviderConfig(llm.url)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
||||||
|
|
||||||
const newSession = yield* acp.request("session/new", { cwd: home, mcpServers: [] })
|
const session = expectOk(
|
||||||
expect(errorCode(newSession.error)).toBe(-32601)
|
yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }),
|
||||||
|
)
|
||||||
|
expect(typeof session.sessionId).toBe("string")
|
||||||
|
expect(selectConfigOption(session.configOptions, "model")?.category).toBe("model")
|
||||||
|
|
||||||
|
const update = yield* acp.waitForNotification<SessionNotification>(
|
||||||
|
"session/update",
|
||||||
|
(params) =>
|
||||||
|
params.sessionId === session.sessionId &&
|
||||||
|
params.update.sessionUpdate === "available_commands_update",
|
||||||
|
)
|
||||||
|
expect(update.params?.sessionId).toBe(session.sessionId)
|
||||||
|
|
||||||
|
const loaded = expectOk(
|
||||||
|
yield* acp.request<LoadSessionResponse>("session/load", {
|
||||||
|
cwd: home,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
mcpServers: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
|
||||||
|
|
||||||
const prompt = yield* acp.request("session/prompt", {
|
const prompt = yield* acp.request("session/prompt", {
|
||||||
sessionId: "ses_missing",
|
sessionId: "ses_missing",
|
||||||
|
|||||||
Reference in New Issue
Block a user