feat(acp): implement acp-next session slice (#29250)

This commit is contained in:
Shoubhit Dash
2026-05-25 22:55:30 +05:30
committed by GitHub
parent e1406e05a3
commit 0373ea9128
4 changed files with 775 additions and 13 deletions
+7 -2
View File
@@ -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))
} }
+408 -4
View File
@@ -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",