diff --git a/packages/opencode/src/acp-next/agent.ts b/packages/opencode/src/acp-next/agent.ts
deleted file mode 100644
index 040c1947e2ed..000000000000
--- a/packages/opencode/src/acp-next/agent.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import {
- RequestError,
- type Agent as ACPAgent,
- type AgentSideConnection,
- type AuthenticateRequest,
- type CancelNotification,
- type CloseSessionRequest,
- type ForkSessionRequest,
- type InitializeRequest,
- type ListSessionsRequest,
- type LoadSessionRequest,
- type NewSessionRequest,
- type PromptRequest,
- type ResumeSessionRequest,
- type SetSessionConfigOptionRequest,
- type SetSessionModelRequest,
- type SetSessionModeRequest,
-} 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 }) {
- return {
- create: (connection: AgentSideConnection) => {
- return new Agent(ACPNextService.make({ sdk: _sdk, connection }))
- },
- }
-}
-
-export class Agent implements ACPAgent {
- constructor(private readonly service: ACPNextService.Interface) {}
-
- initialize(params: InitializeRequest) {
- return run(this.service.initialize(params))
- }
-
- authenticate(params: AuthenticateRequest) {
- return run(this.service.authenticate(params))
- }
-
- newSession(params: NewSessionRequest) {
- return run(this.service.newSession(params))
- }
-
- loadSession(params: LoadSessionRequest) {
- return run(this.service.loadSession(params))
- }
-
- listSessions(params: ListSessionsRequest) {
- return run(this.service.listSessions(params))
- }
-
- resumeSession(params: ResumeSessionRequest) {
- return run(this.service.resumeSession(params))
- }
-
- closeSession(params: CloseSessionRequest) {
- return run(this.service.closeSession(params))
- }
-
- unstable_forkSession(params: ForkSessionRequest) {
- return run(this.service.forkSession(params))
- }
-
- setSessionConfigOption(params: SetSessionConfigOptionRequest) {
- return run(this.service.setSessionConfigOption(params))
- }
-
- setSessionMode(params: SetSessionModeRequest) {
- return run(this.service.setSessionMode(params))
- }
-
- unstable_setSessionModel(params: SetSessionModelRequest) {
- return run(this.service.setSessionModel(params))
- }
-
- prompt(params: PromptRequest) {
- return run(this.service.prompt(params))
- }
-
- cancel(params: CancelNotification) {
- return run(this.service.cancel(params))
- }
-}
-
-function run(effect: Effect.Effect) {
- return Effect.runPromise(effect.pipe(Effect.mapError(ACPNextError.toRequestError))).catch((defect: unknown) => {
- if (defect instanceof RequestError) throw defect
- throw ACPNextError.toRequestError(ACPNextError.fromUnknownDefect(defect))
- })
-}
-
-export * as ACPNext from "./agent"
diff --git a/packages/opencode/src/acp-next/session.ts b/packages/opencode/src/acp-next/session.ts
deleted file mode 100644
index fa803c978156..000000000000
--- a/packages/opencode/src/acp-next/session.ts
+++ /dev/null
@@ -1,232 +0,0 @@
-import type { McpServer } from "@agentclientprotocol/sdk"
-import type { Message, Part } from "@opencode-ai/sdk/v2"
-import { Context, Effect, Layer, Ref } from "effect"
-import type { ModelID, ProviderID } from "../provider/schema"
-import * as ACPNextError from "./error"
-
-export type SelectedModel = {
- providerID: ProviderID
- modelID: ModelID
-}
-
-export type KnownMessagePartMetadata = {
- messageId: string
- partId: string
- partType?: Part["type"]
- role?: Message["role"]
- ignored?: boolean
- toolCallId?: string
- metadata?: unknown
-}
-
-export type Info = {
- id: string
- cwd: string
- mcpServers: readonly McpServer[]
- createdAt: Date
- model?: SelectedModel
- variant?: string
- modeId?: string
- knownParts: ReadonlyMap
-}
-
-export type StoreInput = {
- id: string
- cwd: string
- mcpServers?: readonly McpServer[]
- createdAt?: Date
- model?: SelectedModel
- variant?: string
- modeId?: string
-}
-
-export type RecordPartMetadataInput = {
- sessionId: string
- messageId: string
- partId: string
- partType?: Part["type"]
- role?: Message["role"]
- ignored?: boolean
- toolCallId?: string
- metadata?: unknown
-}
-
-export type PartMetadataLookupInput = {
- sessionId: string
- messageId: string
- partId: string
-}
-
-export type Interface = {
- readonly create: (input: StoreInput) => Effect.Effect
- readonly load: (input: StoreInput) => Effect.Effect
- readonly list: (cwd?: string) => Effect.Effect
- readonly get: (sessionId: string) => Effect.Effect
- readonly tryGet: (sessionId: string) => Effect.Effect
- readonly remove: (sessionId: string) => Effect.Effect
- readonly setModel: (
- sessionId: string,
- model: SelectedModel | undefined,
- ) => Effect.Effect
- readonly getModel: (sessionId: string) => Effect.Effect
- readonly setVariant: (
- sessionId: string,
- variant: string | undefined,
- ) => Effect.Effect
- readonly getVariant: (sessionId: string) => Effect.Effect
- readonly setMode: (
- sessionId: string,
- modeId: string | undefined,
- ) => Effect.Effect
- readonly getMode: (sessionId: string) => Effect.Effect
- readonly recordPartMetadata: (
- input: RecordPartMetadataInput,
- ) => Effect.Effect
- readonly getPartMetadata: (
- input: PartMetadataLookupInput,
- ) => Effect.Effect
- readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect
-}
-
-export class Service extends Context.Service()("@opencode/ACPNext/Session") {}
-
-type State = Map
-
-export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const sessions = yield* Ref.make(new Map())
-
- const store = Effect.fn("ACPNext.Session.store")(function* (input: StoreInput) {
- const session = makeSession(input)
- yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session))
- return snapshot(session)
- })
-
- const tryGet = Effect.fn("ACPNext.Session.tryGet")(function* (sessionId: string) {
- const session = (yield* Ref.get(sessions)).get(sessionId)
- if (!session) return
- return snapshot(session)
- })
-
- const get = Effect.fn("ACPNext.Session.get")(function* (sessionId: string) {
- const session = yield* tryGet(sessionId)
- if (session) return session
- return yield* new ACPNextError.SessionNotFoundError({ sessionId })
- })
-
- const update = Effect.fn("ACPNext.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) {
- const result = yield* Ref.modify(sessions, (state) => {
- const session = state.get(sessionId)
- if (!session) return [undefined, state] as const
- const next = fn(session)
- return [snapshot(next), new Map(state).set(sessionId, next)] as const
- })
- if (result) return result
- return yield* new ACPNextError.SessionNotFoundError({ sessionId })
- })
-
- const remove = Effect.fn("ACPNext.Session.remove")(function* (sessionId: string) {
- return yield* Ref.modify(sessions, (state) => {
- const session = state.get(sessionId)
- if (!session) return [undefined, state] as const
- const next = new Map(state)
- next.delete(sessionId)
- return [snapshot(session), next] as const
- })
- })
-
- const setModel: Interface["setModel"] = Effect.fn("ACPNext.Session.setModel")((sessionId, model) =>
- update(sessionId, (session) => ({ ...session, model })),
- )
-
- const setVariant: Interface["setVariant"] = Effect.fn("ACPNext.Session.setVariant")((sessionId, variant) =>
- update(sessionId, (session) => ({ ...session, variant })),
- )
-
- const setMode: Interface["setMode"] = Effect.fn("ACPNext.Session.setMode")((sessionId, modeId) =>
- update(sessionId, (session) => ({ ...session, modeId })),
- )
-
- const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACPNext.Session.recordPartMetadata")((
- input,
- ) => {
- const metadata = {
- messageId: input.messageId,
- partId: input.partId,
- partType: input.partType,
- role: input.role,
- ignored: input.ignored,
- toolCallId: input.toolCallId,
- metadata: input.metadata,
- }
- return update(input.sessionId, (session) => ({
- ...session,
- knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata),
- })).pipe(Effect.as(metadata))
- })
-
- return Service.of({
- create: store,
- load: store,
- list: Effect.fn("ACPNext.Session.list")(function* (cwd?: string) {
- return [...(yield* Ref.get(sessions)).values()]
- .filter((session) => !cwd || session.cwd === cwd)
- .map(snapshot)
- .toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
- }),
- get,
- tryGet,
- remove,
- setModel,
- getModel: Effect.fn("ACPNext.Session.getModel")(function* (sessionId) {
- return (yield* get(sessionId)).model
- }),
- setVariant,
- getVariant: Effect.fn("ACPNext.Session.getVariant")(function* (sessionId) {
- return (yield* get(sessionId)).variant
- }),
- setMode,
- getMode: Effect.fn("ACPNext.Session.getMode")(function* (sessionId) {
- return (yield* get(sessionId)).modeId
- }),
- recordPartMetadata,
- getPartMetadata: Effect.fn("ACPNext.Session.getPartMetadata")(function* (input) {
- return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input))
- }),
- tryGetPartMetadata: Effect.fn("ACPNext.Session.tryGetPartMetadata")(function* (input) {
- return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input))
- }),
- })
- }),
-)
-
-export const defaultLayer = layer
-
-function makeSession(input: StoreInput): Info {
- return {
- id: input.id,
- cwd: input.cwd,
- mcpServers: [...(input.mcpServers ?? [])],
- createdAt: input.createdAt ? new Date(input.createdAt) : new Date(),
- model: input.model,
- variant: input.variant,
- modeId: input.modeId,
- knownParts: new Map(),
- }
-}
-
-function snapshot(session: Info): Info {
- return {
- ...session,
- mcpServers: [...session.mcpServers],
- createdAt: new Date(session.createdAt),
- knownParts: new Map(session.knownParts),
- }
-}
-
-function partMetadataKey(input: { messageId: string; partId: string }) {
- return `${input.messageId}:${input.partId}`
-}
-
-export * as ACPNextSession from "./session"
diff --git a/packages/opencode/src/acp/README.md b/packages/opencode/src/acp/README.md
deleted file mode 100644
index aab33259bb18..000000000000
--- a/packages/opencode/src/acp/README.md
+++ /dev/null
@@ -1,174 +0,0 @@
-# ACP (Agent Client Protocol) Implementation
-
-This directory contains a clean, protocol-compliant implementation of the [Agent Client Protocol](https://agentclientprotocol.com/) for opencode.
-
-## Architecture
-
-The implementation follows a clean separation of concerns:
-
-### Core Components
-
-- **`agent.ts`** - Implements the `Agent` interface from `@agentclientprotocol/sdk`
- - Handles initialization and capability negotiation
- - Manages session lifecycle (`session/new`, `session/load`)
- - Processes prompts and returns responses
- - Properly implements ACP protocol v1
-
-- **`client.ts`** - Implements the `Client` interface for client-side capabilities
- - File operations (`readTextFile`, `writeTextFile`)
- - Permission requests (auto-approves for now)
- - Terminal support (stub implementation)
-
-- **`session.ts`** - Session state management
- - Creates and tracks ACP sessions
- - Maps ACP sessions to internal opencode sessions
- - Maintains working directory context
- - Handles MCP server configurations
-
-- **`server.ts`** - ACP server startup and lifecycle
- - Sets up JSON-RPC over stdio using the official library
- - Manages graceful shutdown on SIGTERM/SIGINT
- - Provides Instance context for the agent
-
-- **`types.ts`** - Type definitions for internal use
-
-## Usage
-
-### Command Line
-
-```bash
-# Start the ACP server in the current directory
-opencode acp
-
-# Start in a specific directory
-opencode acp --cwd /path/to/project
-```
-
-### Question Tool Opt-In
-
-ACP excludes `QuestionTool` by default.
-
-```bash
-OPENCODE_ENABLE_QUESTION_TOOL=1 opencode acp
-```
-
-Enable this only for ACP clients that support interactive question prompts.
-
-### Programmatic
-
-```typescript
-import { ACPServer } from "./acp/server"
-
-await ACPServer.start()
-```
-
-### Integration with Zed
-
-Add to your Zed configuration (`~/.config/zed/settings.json`):
-
-```json
-{
- "agent_servers": {
- "OpenCode": {
- "command": "opencode",
- "args": ["acp"]
- }
- }
-}
-```
-
-## Protocol Compliance
-
-This implementation follows the ACP specification v1:
-
-✅ **Initialization**
-
-- Proper `initialize` request/response with protocol version negotiation
-- Capability advertisement (`agentCapabilities`)
-- Authentication support (stub)
-
-✅ **Session Management**
-
-- `session/new` - Create new conversation sessions
-- `session/load` - Resume existing sessions (basic support)
-- Working directory context (`cwd`)
-- MCP server configuration support
-
-✅ **Prompting**
-
-- `session/prompt` - Process user messages
-- Content block handling (text, resources)
-- Response with stop reasons
-
-✅ **Client Capabilities**
-
-- File read/write operations
-- Permission requests
-- Terminal support (stub for future)
-
-## Current Limitations
-
-### Not Yet Implemented
-
-1. **Streaming Responses** - Currently returns complete responses instead of streaming via `session/update` notifications
-2. **Tool Call Reporting** - Doesn't report tool execution progress
-3. **Session Modes** - No mode switching support yet
-4. **Authentication** - No actual auth implementation
-5. **Terminal Support** - Placeholder only
-6. **Session Persistence** - `session/load` doesn't restore actual conversation history
-
-### Future Enhancements
-
-- **Real-time Streaming**: Implement `session/update` notifications for progressive responses
-- **Tool Call Visibility**: Report tool executions as they happen
-- **Session Persistence**: Save and restore full conversation history
-- **Mode Support**: Implement different operational modes (ask, code, etc.)
-- **Enhanced Permissions**: More sophisticated permission handling
-- **Terminal Integration**: Full terminal support via opencode's bash tool
-
-## Testing
-
-```bash
-# Run ACP tests
-bun test test/acp.test.ts
-
-# Test manually with stdio
-echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}' | opencode acp
-```
-
-## Design Decisions
-
-### Why the Official Library?
-
-We use `@agentclientprotocol/sdk` instead of implementing JSON-RPC ourselves because:
-
-- Ensures protocol compliance
-- Handles edge cases and future protocol versions
-- Reduces maintenance burden
-- Works with other ACP clients automatically
-
-### Clean Architecture
-
-Each component has a single responsibility:
-
-- **Agent** = Protocol interface
-- **Client** = Client-side operations
-- **Session** = State management
-- **Server** = Lifecycle and I/O
-
-This makes the codebase maintainable and testable.
-
-### Mapping to OpenCode
-
-ACP sessions map cleanly to opencode's internal session model:
-
-- ACP `session/new` → creates internal Session
-- ACP `session/prompt` → uses SessionPrompt.prompt()
-- Working directory context preserved per-session
-- Tool execution uses existing ToolRegistry
-
-## References
-
-- [ACP Specification](https://agentclientprotocol.com/)
-- [TypeScript Library](https://github.com/agentclientprotocol/typescript-sdk)
-- [Protocol Examples](https://github.com/agentclientprotocol/typescript-sdk/tree/main/src/examples)
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
index 8b74b9c9bad3..a7c59a261551 100644
--- a/packages/opencode/src/acp/agent.ts
+++ b/packages/opencode/src/acp/agent.ts
@@ -3,1964 +3,93 @@ import {
type Agent as ACPAgent,
type AgentSideConnection,
type AuthenticateRequest,
- type AuthMethod,
type CancelNotification,
type CloseSessionRequest,
- type CloseSessionResponse,
type ForkSessionRequest,
- type ForkSessionResponse,
type InitializeRequest,
- type InitializeResponse,
type ListSessionsRequest,
- type ListSessionsResponse,
type LoadSessionRequest,
type NewSessionRequest,
- type PermissionOption,
- type PlanEntry,
type PromptRequest,
type ResumeSessionRequest,
- type ResumeSessionResponse,
- type Role,
- type SessionInfo,
- type SetSessionModelRequest,
- type SessionConfigOption,
type SetSessionConfigOptionRequest,
- type SetSessionConfigOptionResponse,
+ type SetSessionModelRequest,
type SetSessionModeRequest,
- type SetSessionModeResponse,
- type ToolCallContent,
- type ToolKind,
- type Usage,
} from "@agentclientprotocol/sdk"
-
-import * as Log from "@opencode-ai/core/util/log"
-import { pathToFileURL } from "url"
-import { Filesystem } from "@/util/filesystem"
-import { Hash } from "@opencode-ai/core/util/hash"
-import { ACPSessionManager } from "./session"
-import type { ACPConfig } from "./types"
-import { ACPRuntime } from "./runtime"
-import { Provider } from "@/provider/provider"
-import { ModelID, ProviderID } from "../provider/schema"
-import { MessageV2 } from "@/session/message-v2"
-import { ConfigMCP } from "@/config/mcp"
-import { Todo } from "@/session/todo"
-import { Result, Schema } from "effect"
-import { LoadAPIKeyError } from "ai"
-import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
-import { applyPatch } from "diff"
-import { InstallationVersion } from "@opencode-ai/core/installation/version"
-import { ShellID } from "@/tool/shell/id"
-
-type ModeOption = { id: string; name: string; description?: string }
-type ModelOption = { modelId: string; name: string }
-const decodeTodos = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Array(Todo.Info)))
-
-const DEFAULT_VARIANT_VALUE = "default"
-
-const log = Log.create({ service: "acp-agent" })
-
-async function getContextLimit(
- sdk: OpencodeClient,
- providerID: ProviderID,
- modelID: ModelID,
- directory: string,
-): Promise {
- const providers = await sdk.config
- .providers({ directory })
- .then((x) => x.data?.providers ?? [])
- .catch((error) => {
- log.error("failed to get providers for context limit", { error })
- return []
- })
-
- const provider = providers.find((p) => p.id === providerID)
- const model = provider?.models[modelID]
- return model?.limit.context ?? null
-}
-
-async function sendUsageUpdate(
- connection: AgentSideConnection,
- sdk: OpencodeClient,
- sessionID: string,
- directory: string,
-): Promise {
- const messages = await sdk.session
- .messages({ sessionID, directory }, { throwOnError: true })
- .then((x) => x.data)
- .catch((error) => {
- log.error("failed to fetch messages for usage update", { error })
- return undefined
- })
-
- if (!messages) return
-
- const assistantMessages = messages.filter(
- (m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant",
- )
-
- const lastAssistant = assistantMessages[assistantMessages.length - 1]
- if (!lastAssistant) return
-
- const msg = lastAssistant.info
- if (!msg.providerID || !msg.modelID) return
- const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory)
-
- if (!size) {
- // Cannot calculate usage without known context size
- return
- }
-
- const used = msg.tokens.input + (msg.tokens.cache?.read ?? 0)
- const totalCost = assistantMessages.reduce((sum, m) => sum + m.info.cost, 0)
-
- await connection
- .sessionUpdate({
- sessionId: sessionID,
- update: {
- sessionUpdate: "usage_update",
- used,
- size,
- cost: { amount: totalCost, currency: "USD" },
- },
- })
- .catch((error) => {
- log.error("failed to send usage update", { error })
- })
-}
+import { Effect } from "effect"
+import type { OpencodeClient } from "@opencode-ai/sdk/v2"
+import * as ACPError from "./error"
+import * as ACPService from "./service"
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
return {
- create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
- return new Agent(connection, fullConfig)
+ create: (connection: AgentSideConnection) => {
+ return new Agent(ACPService.make({ sdk: _sdk, connection }))
},
}
}
export class Agent implements ACPAgent {
- private connection: AgentSideConnection
- private config: ACPConfig
- private sdk: OpencodeClient
- private sessionManager: ACPSessionManager
- private eventAbort = new AbortController()
- private eventStarted = false
- private shellSnapshots = new Map()
- private toolStarts = new Set()
- private permissionQueues = new Map>()
- private permissionOptions: PermissionOption[] = [
- { optionId: "once", kind: "allow_once", name: "Allow once" },
- { optionId: "always", kind: "allow_always", name: "Always allow" },
- { optionId: "reject", kind: "reject_once", name: "Reject" },
- ]
-
- constructor(connection: AgentSideConnection, config: ACPConfig) {
- this.connection = connection
- this.config = config
- this.sdk = config.sdk
- this.sessionManager = new ACPSessionManager(this.sdk)
- this.startEventSubscription()
- }
-
- private startEventSubscription() {
- if (this.eventStarted) return
- this.eventStarted = true
- this.runEventSubscription().catch((error) => {
- if (this.eventAbort.signal.aborted) return
- log.error("event subscription failed", { error })
- })
- }
-
- private async runEventSubscription() {
- while (true) {
- if (this.eventAbort.signal.aborted) return
- const events = await this.sdk.global.event({
- signal: this.eventAbort.signal,
- })
- for await (const event of events.stream) {
- if (this.eventAbort.signal.aborted) return
- const payload = event?.payload
- if (!payload) continue
- await this.handleEvent(payload as Event).catch((error) => {
- log.error("failed to handle event", { error, type: payload.type })
- })
- }
- }
- }
-
- private async handleEvent(event: Event) {
- switch (event.type) {
- case "permission.asked": {
- const permission = event.properties
- const session = this.sessionManager.tryGet(permission.sessionID)
- if (!session) return
-
- const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()
- const next = prev
- .then(async () => {
- const directory = session.cwd
-
- const res = await this.connection
- .requestPermission({
- sessionId: permission.sessionID,
- toolCall: {
- toolCallId: permission.tool?.callID ?? permission.id,
- status: "pending",
- title: permission.permission,
- rawInput: permission.metadata,
- kind: toToolKind(permission.permission),
- locations: toLocations(permission.permission, permission.metadata),
- },
- options: this.permissionOptions,
- })
- .catch(async (error) => {
- log.error("failed to request permission from ACP", {
- error,
- permissionID: permission.id,
- sessionID: permission.sessionID,
- })
- await this.sdk.permission.reply({
- requestID: permission.id,
- reply: "reject",
- directory,
- })
- return undefined
- })
-
- if (!res) return
- if (res.outcome.outcome !== "selected") {
- await this.sdk.permission.reply({
- requestID: permission.id,
- reply: "reject",
- directory,
- })
- return
- }
-
- if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
- const metadata = permission.metadata || {}
- const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
- const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
- const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : ""
- const newContent = getNewContent(content, diff)
-
- if (newContent) {
- void this.connection.writeTextFile({
- sessionId: session.id,
- path: filepath,
- content: newContent,
- })
- }
- }
-
- await this.sdk.permission.reply({
- requestID: permission.id,
- reply: res.outcome.optionId as "once" | "always" | "reject",
- directory,
- })
- })
- .catch((error) => {
- log.error("failed to handle permission", { error, permissionID: permission.id })
- })
- .finally(() => {
- if (this.permissionQueues.get(permission.sessionID) === next) {
- this.permissionQueues.delete(permission.sessionID)
- }
- })
- this.permissionQueues.set(permission.sessionID, next)
- return
- }
-
- case "message.part.updated": {
- log.info("message part updated", { event: event.properties })
- const props = event.properties
- const part = props.part
- const session = this.sessionManager.tryGet(part.sessionID)
- if (!session) return
- const sessionId = session.id
-
- if (part.type === "tool") {
- await this.toolStart(sessionId, part)
-
- switch (part.state.status) {
- case "pending":
- this.shellSnapshots.delete(part.callID)
- return
-
- case "running":
- const output = this.shellOutput(part)
- const content: ToolCallContent[] = []
- if (output) {
- const hash = Hash.fast(output)
- if (part.tool === ShellID.ToolID) {
- if (this.shellSnapshots.get(part.callID) === hash) {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "tool_call_update",
- toolCallId: part.callID,
- status: "in_progress",
- kind: toToolKind(part.tool),
- title: part.tool,
- locations: toLocations(part.tool, part.state.input),
- rawInput: part.state.input,
- },
- })
- .catch((error) => {
- log.error("failed to send tool in_progress to ACP", { error })
- })
- return
- }
- this.shellSnapshots.set(part.callID, hash)
- }
- content.push({
- type: "content",
- content: {
- type: "text",
- text: output,
- },
- })
- }
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "tool_call_update",
- toolCallId: part.callID,
- status: "in_progress",
- kind: toToolKind(part.tool),
- title: part.tool,
- locations: toLocations(part.tool, part.state.input),
- rawInput: part.state.input,
- ...(content.length > 0 && { content }),
- },
- })
- .catch((error) => {
- log.error("failed to send tool in_progress to ACP", { error })
- })
- return
-
- case "completed": {
- this.toolStarts.delete(part.callID)
- this.shellSnapshots.delete(part.callID)
- const kind = toToolKind(part.tool)
- const content = completedToolContent(part, kind)
-
- if (part.tool === "todowrite") {
- const parsedTodos = decodeTodos(part.state.output)
- if (Result.isSuccess(parsedTodos)) {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "plan",
- entries: parsedTodos.success.map((todo) => {
- const status: PlanEntry["status"] =
- todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
- return {
- priority: "medium",
- status,
- content: todo.content,
- }
- }),
- },
- })
- .catch((error) => {
- log.error("failed to send session update for todo", { error })
- })
- } else {
- log.error("failed to parse todo output", { error: parsedTodos.failure })
- }
- }
-
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "tool_call_update",
- toolCallId: part.callID,
- status: "completed",
- kind,
- content,
- title: part.state.title,
- rawInput: part.state.input,
- rawOutput: completedToolRawOutput(part),
- },
- })
- .catch((error) => {
- log.error("failed to send tool completed to ACP", { error })
- })
- return
- }
- case "error":
- this.toolStarts.delete(part.callID)
- this.shellSnapshots.delete(part.callID)
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "tool_call_update",
- toolCallId: part.callID,
- status: "failed",
- kind: toToolKind(part.tool),
- title: part.tool,
- rawInput: part.state.input,
- content: [
- {
- type: "content",
- content: {
- type: "text",
- text: part.state.error,
- },
- },
- ],
- rawOutput: {
- error: part.state.error,
- metadata: part.state.metadata,
- },
- },
- })
- .catch((error) => {
- log.error("failed to send tool error to ACP", { error })
- })
- return
- }
- }
-
- // ACP clients already know the prompt they just submitted, so replaying
- // live user parts duplicates the message. We still replay user history in
- // loadSession() and forkSession() via processMessage().
- if (part.type !== "text" && part.type !== "file") return
-
- return
- }
-
- case "message.part.delta": {
- const props = event.properties
- const session = this.sessionManager.tryGet(props.sessionID)
- if (!session) return
- const sessionId = session.id
-
- const message = await this.sdk.session
- .message(
- {
- sessionID: props.sessionID,
- messageID: props.messageID,
- directory: session.cwd,
- },
- { throwOnError: true },
- )
- .then((x) => x.data)
- .catch((error) => {
- log.error("unexpected error when fetching message", { error })
- return undefined
- })
-
- if (!message || message.info.role !== "assistant") return
-
- const part = message.parts.find((p) => p.id === props.partID)
- if (!part) return
-
- if (part.type === "text" && props.field === "text" && part.ignored !== true) {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "agent_message_chunk",
- messageId: props.messageID,
- content: {
- type: "text",
- text: props.delta,
- },
- },
- })
- .catch((error) => {
- log.error("failed to send text delta to ACP", { error })
- })
- return
- }
-
- if (part.type === "reasoning" && props.field === "text") {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "agent_thought_chunk",
- messageId: props.messageID,
- content: {
- type: "text",
- text: props.delta,
- },
- },
- })
- .catch((error) => {
- log.error("failed to send reasoning delta to ACP", { error })
- })
- }
- return
- }
- }
- }
-
- async initialize(params: InitializeRequest): Promise {
- log.info("initialize", { protocolVersion: params.protocolVersion })
-
- const authMethod: AuthMethod = {
- description: "Run `opencode auth login` in the terminal",
- name: "Login with opencode",
- id: "opencode-login",
- }
-
- // If client supports terminal-auth capability, use that instead.
- if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
- authMethod._meta = {
- "terminal-auth": {
- command: "opencode",
- args: ["auth", "login"],
- label: "OpenCode Login",
- },
- }
- }
-
- return {
- protocolVersion: 1,
- agentCapabilities: {
- loadSession: true,
- mcpCapabilities: {
- http: true,
- sse: true,
- },
- promptCapabilities: {
- embeddedContext: true,
- image: true,
- },
- sessionCapabilities: {
- close: {},
- fork: {},
- list: {},
- resume: {},
- },
- },
- authMethods: [authMethod],
- agentInfo: {
- name: "OpenCode",
- version: InstallationVersion,
- },
- }
- }
-
- async authenticate(_params: AuthenticateRequest) {
- throw new Error("Authentication not implemented")
- }
-
- async newSession(params: NewSessionRequest) {
- const directory = params.cwd
- try {
- const model = await defaultModel(this.config, directory)
-
- // Store ACP session state
- const state = await this.sessionManager.create(params.cwd, params.mcpServers, model)
- const sessionId = state.id
+ constructor(private readonly service: ACPService.Interface) {}
- log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length })
-
- const load = await this.loadSessionMode({
- cwd: directory,
- mcpServers: params.mcpServers,
- sessionId,
- })
-
- return {
- sessionId,
- configOptions: load.configOptions,
- models: load.models,
- modes: load.modes,
- _meta: load._meta,
- }
- } catch (e) {
- const error = MessageV2.fromError(e, {
- providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
- })
- if (LoadAPIKeyError.isInstance(error)) {
- throw RequestError.authRequired()
- }
- throw e
- }
+ initialize(params: InitializeRequest) {
+ return run(this.service.initialize(params))
}
- async loadSession(params: LoadSessionRequest) {
- const directory = params.cwd
- const sessionId = params.sessionId
-
- try {
- const model = await defaultModel(this.config, directory)
-
- // Store ACP session state
- await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
-
- const messages = await this.loadSessionMessages(directory, sessionId)
- this.restoreSessionStateFromMessages(sessionId, messages)
-
- log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
-
- const result = await this.loadSessionMode({
- cwd: directory,
- mcpServers: params.mcpServers,
- sessionId,
- })
-
- for (const msg of messages ?? []) {
- log.debug("replay message", msg)
- await this.processMessage(msg)
- }
-
- await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
-
- return result
- } catch (e) {
- const error = MessageV2.fromError(e, {
- providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
- })
- if (LoadAPIKeyError.isInstance(error)) {
- throw RequestError.authRequired()
- }
- throw e
- }
+ authenticate(params: AuthenticateRequest) {
+ return run(this.service.authenticate(params))
}
- async listSessions(params: ListSessionsRequest): Promise {
- try {
- const cursor = params.cursor ? Number(params.cursor) : undefined
- const limit = 100
-
- const sessions = await this.sdk.session
- .list(
- {
- directory: params.cwd ?? undefined,
- roots: true,
- },
- { throwOnError: true },
- )
- .then((x) => x.data ?? [])
-
- const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
- const filtered = cursor ? sorted.filter((s) => s.time.updated < cursor) : sorted
- const page = filtered.slice(0, limit)
-
- const entries: SessionInfo[] = page.map((session) => ({
- sessionId: session.id,
- cwd: session.directory,
- title: session.title,
- updatedAt: new Date(session.time.updated).toISOString(),
- }))
-
- const last = page[page.length - 1]
- const next = filtered.length > limit && last ? String(last.time.updated) : undefined
-
- const response: ListSessionsResponse = {
- sessions: entries,
- }
- if (next) response.nextCursor = next
- return response
- } catch (e) {
- const error = MessageV2.fromError(e, {
- providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
- })
- if (LoadAPIKeyError.isInstance(error)) {
- throw RequestError.authRequired()
- }
- throw e
- }
+ newSession(params: NewSessionRequest) {
+ return run(this.service.newSession(params))
}
- async unstable_forkSession(params: ForkSessionRequest): Promise {
- const directory = params.cwd
- const mcpServers = params.mcpServers ?? []
-
- try {
- const model = await defaultModel(this.config, directory)
-
- const forked = await this.sdk.session
- .fork(
- {
- sessionID: params.sessionId,
- directory,
- },
- { throwOnError: true },
- )
- .then((x) => x.data)
-
- if (!forked) {
- throw new Error("Fork session returned no data")
- }
-
- const sessionId = forked.id
- await this.sessionManager.load(sessionId, directory, mcpServers, model)
-
- const messages = await this.loadSessionMessages(directory, sessionId)
- this.restoreSessionStateFromMessages(sessionId, messages)
-
- log.info("fork_session", { sessionId, mcpServers: mcpServers.length })
-
- const mode = await this.loadSessionMode({
- cwd: directory,
- mcpServers,
- sessionId,
- })
-
- for (const msg of messages ?? []) {
- log.debug("replay message", msg)
- await this.processMessage(msg)
- }
-
- await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
-
- return mode
- } catch (e) {
- const error = MessageV2.fromError(e, {
- providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
- })
- if (LoadAPIKeyError.isInstance(error)) {
- throw RequestError.authRequired()
- }
- throw e
- }
+ loadSession(params: LoadSessionRequest) {
+ return run(this.service.loadSession(params))
}
- async resumeSession(params: ResumeSessionRequest): Promise {
- const directory = params.cwd
- const sessionId = params.sessionId
- const mcpServers = params.mcpServers ?? []
-
- try {
- const model = await defaultModel(this.config, directory)
- await this.sessionManager.load(sessionId, directory, mcpServers, model)
-
- const messages = await this.loadSessionMessages(directory, sessionId, 20)
- this.restoreSessionStateFromMessages(sessionId, messages)
-
- log.info("resume_session", { sessionId, mcpServers: mcpServers.length })
-
- const result = await this.loadSessionMode({
- cwd: directory,
- mcpServers,
- sessionId,
- })
-
- await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
-
- return result
- } catch (e) {
- const error = MessageV2.fromError(e, {
- providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
- })
- if (LoadAPIKeyError.isInstance(error)) {
- throw RequestError.authRequired()
- }
- throw e
- }
+ listSessions(params: ListSessionsRequest) {
+ return run(this.service.listSessions(params))
}
- async closeSession(params: CloseSessionRequest): Promise {
- const session = this.sessionManager.remove(params.sessionId)
- if (!session) return {}
-
- await this.sdk.session
- .abort(
- {
- sessionID: params.sessionId,
- directory: session.cwd,
- },
- { throwOnError: true },
- )
- .catch((error) => {
- log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
- })
-
- this.permissionQueues.delete(params.sessionId)
- log.info("close_session", { sessionId: params.sessionId })
- return {}
- }
-
- private async processMessage(message: SessionMessageResponse) {
- log.debug("process message", message)
- if (message.info.role !== "assistant" && message.info.role !== "user") return
- const sessionId = message.info.sessionID
-
- for (const part of message.parts) {
- if (part.type === "tool") {
- await this.toolStart(sessionId, part)
- switch (part.state.status) {
- case "pending":
- this.shellSnapshots.delete(part.callID)
- break
- case "running":
- const output = this.shellOutput(part)
- const runningContent: ToolCallContent[] = []
- if (output) {
- runningContent.push({
- type: "content",
- content: {
- type: "text",
- text: output,
- },
- })
- }
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "tool_call_update",
- toolCallId: part.callID,
- status: "in_progress",
- kind: toToolKind(part.tool),
- title: part.tool,
- locations: toLocations(part.tool, part.state.input),
- rawInput: part.state.input,
- ...(runningContent.length > 0 && { content: runningContent }),
- },
- })
- .catch((err) => {
- log.error("failed to send tool in_progress to ACP", { error: err })
- })
- break
- case "completed":
- this.toolStarts.delete(part.callID)
- this.shellSnapshots.delete(part.callID)
- const kind = toToolKind(part.tool)
- const content = completedToolContent(part, kind)
-
- if (part.tool === "todowrite") {
- const parsedTodos = decodeTodos(part.state.output)
- if (Result.isSuccess(parsedTodos)) {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "plan",
- entries: parsedTodos.success.map((todo) => {
- const status: PlanEntry["status"] =
- todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
- return {
- priority: "medium",
- status,
- content: todo.content,
- }
- }),
- },
- })
- .catch((err) => {
- log.error("failed to send session update for todo", { error: err })
- })
- } else {
- log.error("failed to parse todo output", { error: parsedTodos.failure })
- }
- }
-
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "tool_call_update",
- toolCallId: part.callID,
- status: "completed",
- kind,
- content,
- title: part.state.title,
- rawInput: part.state.input,
- rawOutput: completedToolRawOutput(part),
- },
- })
- .catch((err) => {
- log.error("failed to send tool completed to ACP", { error: err })
- })
- break
- case "error":
- this.toolStarts.delete(part.callID)
- this.shellSnapshots.delete(part.callID)
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "tool_call_update",
- toolCallId: part.callID,
- status: "failed",
- kind: toToolKind(part.tool),
- title: part.tool,
- rawInput: part.state.input,
- content: [
- {
- type: "content",
- content: {
- type: "text",
- text: part.state.error,
- },
- },
- ],
- rawOutput: {
- error: part.state.error,
- metadata: part.state.metadata,
- },
- },
- })
- .catch((err) => {
- log.error("failed to send tool error to ACP", { error: err })
- })
- break
- }
- } else if (part.type === "text") {
- if (part.text) {
- const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk",
- messageId: message.info.id,
- content: {
- type: "text",
- text: part.text,
- ...(audience && { annotations: { audience } }),
- },
- },
- })
- .catch((err) => {
- log.error("failed to send text to ACP", { error: err })
- })
- }
- } else if (part.type === "file") {
- // Replay file attachments as appropriate ACP content blocks.
- // OpenCode stores files internally as { type: "file", url, filename, mime }.
- // We convert these back to ACP blocks based on the URL scheme and MIME type:
- // - file:// URLs → resource_link
- // - data: URLs with image/* → image block
- // - data: URLs with text/* or application/json → resource with text
- // - data: URLs with other types → resource with blob
- const url = part.url
- const filename = part.filename ?? "file"
- const mime = part.mime || "application/octet-stream"
- const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk"
-
- if (url.startsWith("file://")) {
- // Local file reference - send as resource_link
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: messageChunk,
- messageId: message.info.id,
- content: { type: "resource_link", uri: url, name: filename, mimeType: mime },
- },
- })
- .catch((err) => {
- log.error("failed to send resource_link to ACP", { error: err })
- })
- } else if (url.startsWith("data:")) {
- // Embedded content - parse data URL and send as appropriate block type
- const base64Match = url.match(/^data:([^;]+);base64,(.*)$/)
- const dataMime = base64Match?.[1]
- const base64Data = base64Match?.[2] ?? ""
-
- const effectiveMime = dataMime || mime
-
- if (effectiveMime.startsWith("image/")) {
- // Image - send as image block
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: messageChunk,
- messageId: message.info.id,
- content: {
- type: "image",
- mimeType: effectiveMime,
- data: base64Data,
- uri: pathToFileURL(filename).href,
- },
- },
- })
- .catch((err) => {
- log.error("failed to send image to ACP", { error: err })
- })
- } else {
- // Non-image: text types get decoded, binary types stay as blob
- const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
- const fileUri = pathToFileURL(filename).href
- const resource = isText
- ? {
- uri: fileUri,
- mimeType: effectiveMime,
- text: Buffer.from(base64Data, "base64").toString("utf-8"),
- }
- : { uri: fileUri, mimeType: effectiveMime, blob: base64Data }
-
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: messageChunk,
- messageId: message.info.id,
- content: { type: "resource", resource },
- },
- })
- .catch((err) => {
- log.error("failed to send resource to ACP", { error: err })
- })
- }
- }
- // URLs that don't match file:// or data: are skipped (unsupported)
- } else if (part.type === "reasoning") {
- if (part.text) {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "agent_thought_chunk",
- messageId: message.info.id,
- content: {
- type: "text",
- text: part.text,
- },
- },
- })
- .catch((err) => {
- log.error("failed to send reasoning to ACP", { error: err })
- })
- }
- }
- }
+ resumeSession(params: ResumeSessionRequest) {
+ return run(this.service.resumeSession(params))
}
- private shellOutput(part: ToolPart) {
- if (part.tool !== ShellID.ToolID) return
- if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
- const output = part.state.metadata["output"]
- if (typeof output !== "string") return
- return output
+ closeSession(params: CloseSessionRequest) {
+ return run(this.service.closeSession(params))
}
- private async toolStart(sessionId: string, part: ToolPart) {
- if (this.toolStarts.has(part.callID)) return
- this.toolStarts.add(part.callID)
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "tool_call",
- toolCallId: part.callID,
- title: part.tool,
- kind: toToolKind(part.tool),
- status: "pending",
- locations: [],
- rawInput: {},
- },
- })
- .catch((error) => {
- log.error("failed to send tool pending to ACP", { error })
- })
+ unstable_forkSession(params: ForkSessionRequest) {
+ return run(this.service.forkSession(params))
}
- private async loadAvailableModes(directory: string): Promise {
- const agents = await this.config.sdk.app
- .agents(
- {
- directory,
- },
- { throwOnError: true },
- )
- .then((resp) => resp.data!)
-
- return agents
- .filter((agent) => agent.mode !== "subagent" && !agent.hidden)
- .map((agent) => ({
- id: agent.name,
- name: agent.name,
- description: agent.description,
- }))
+ setSessionConfigOption(params: SetSessionConfigOptionRequest) {
+ return run(this.service.setSessionConfigOption(params))
}
- private async resolveModeState(
- directory: string,
- sessionId: string,
- ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> {
- const availableModes = await this.loadAvailableModes(directory)
- const storedModeId = this.sessionManager.get(sessionId).modeId
- if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) {
- return { availableModes, currentModeId: storedModeId }
- }
-
- const currentModeId = await (async () => {
- if (!availableModes.length) return undefined
- const defaultAgent = await ACPRuntime.defaultAgentInfo(directory)
- const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
- this.sessionManager.setMode(sessionId, resolvedModeId)
- return resolvedModeId
- })()
-
- return { availableModes, currentModeId }
+ setSessionMode(params: SetSessionModeRequest) {
+ return run(this.service.setSessionMode(params))
}
- private async loadSessionMode(params: LoadSessionRequest) {
- const directory = params.cwd
- const sessionId = params.sessionId
- const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory))
-
- const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
- const entries = sortProvidersByName(providers)
- const availableVariants = modelVariantsFromProviders(entries, model)
- const currentVariant = this.sessionManager.getVariant(sessionId)
- if (currentVariant && !availableVariants.includes(currentVariant)) {
- this.sessionManager.setVariant(sessionId, undefined)
- }
- const availableModels = buildAvailableModels(entries)
- const modeState = await this.resolveModeState(directory, sessionId)
- const currentModeId = modeState.currentModeId
- const modes = currentModeId
- ? {
- availableModes: modeState.availableModes,
- currentModeId,
- }
- : undefined
-
- const commands = await this.config.sdk.command
- .list(
- {
- directory,
- },
- { throwOnError: true },
- )
- .then((resp) => resp.data!)
-
- const availableCommands = commands.map((command) => ({
- name: command.name,
- description: command.description ?? "",
- }))
- const names = new Set(availableCommands.map((c) => c.name))
- if (!names.has("compact"))
- availableCommands.push({
- name: "compact",
- description: "compact the session",
- })
-
- const mcpServers: Record = {}
- for (const server of params.mcpServers) {
- if ("type" in server) {
- mcpServers[server.name] = {
- url: server.url,
- headers: server.headers.reduce>((acc, { name, value }) => {
- acc[name] = value
- return acc
- }, {}),
- type: "remote",
- }
- } else {
- mcpServers[server.name] = {
- type: "local",
- command: [server.command, ...server.args],
- environment: server.env.reduce>((acc, { name, value }) => {
- acc[name] = value
- return acc
- }, {}),
- }
- }
- }
-
- await Promise.all(
- Object.entries(mcpServers).map(async ([key, mcp]) => {
- await this.sdk.mcp
- .add(
- {
- directory,
- name: key,
- config: mcp,
- },
- { throwOnError: true },
- )
- .catch((error) => {
- log.error("failed to add mcp server", { name: key, error })
- })
- }),
- )
-
- setTimeout(() => {
- void this.connection.sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "available_commands_update",
- availableCommands,
- },
- })
- }, 0)
-
- return {
- sessionId,
- models: {
- currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
- availableModels,
- },
- modes,
- configOptions: buildConfigOptions({
- currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
- availableModels,
- currentVariant,
- availableVariants,
- modes,
- }),
- _meta: buildVariantMeta({
- model,
- variant: this.sessionManager.getVariant(sessionId),
- availableVariants,
- }),
- }
+ unstable_setSessionModel(params: SetSessionModelRequest) {
+ return run(this.service.setSessionModel(params))
}
- async unstable_setSessionModel(params: SetSessionModelRequest) {
- const session = this.sessionManager.get(params.sessionId)
- const providers = await this.sdk.config
- .providers({ directory: session.cwd }, { throwOnError: true })
- .then((x) => x.data!.providers)
-
- const selection = parseModelSelection(params.modelId, providers)
- this.sessionManager.setModel(session.id, selection.model)
- this.sessionManager.setVariant(session.id, selection.variant)
-
- const entries = sortProvidersByName(providers)
- const availableVariants = modelVariantsFromProviders(entries, selection.model)
- const modeState = await this.resolveModeState(session.cwd, session.id)
- const modes = modeState.currentModeId
- ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
- : undefined
-
- await this.connection.sessionUpdate({
- sessionId: session.id,
- update: {
- sessionUpdate: "config_option_update",
- configOptions: buildConfigOptions({
- currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false),
- availableModels: buildAvailableModels(entries),
- currentVariant: selection.variant,
- availableVariants,
- modes,
- }),
- },
- })
-
- return {
- _meta: buildVariantMeta({
- model: selection.model,
- variant: selection.variant,
- availableVariants,
- }),
- }
+ prompt(params: PromptRequest) {
+ return run(this.service.prompt(params))
}
- async setSessionMode(params: SetSessionModeRequest): Promise {
- const session = this.sessionManager.get(params.sessionId)
- const availableModes = await this.loadAvailableModes(session.cwd)
- if (!availableModes.some((mode) => mode.id === params.modeId)) {
- throw new Error(`Agent not found: ${params.modeId}`)
- }
- this.sessionManager.setMode(params.sessionId, params.modeId)
- }
-
- async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise {
- const session = this.sessionManager.get(params.sessionId)
- const providers = await this.sdk.config
- .providers({ directory: session.cwd }, { throwOnError: true })
- .then((x) => x.data!.providers)
- const entries = sortProvidersByName(providers)
-
- if (params.configId === "model") {
- if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string")
- const selection = parseModelSelection(params.value, providers)
- this.sessionManager.setModel(session.id, selection.model)
- this.sessionManager.setVariant(session.id, selection.variant)
- } else if (params.configId === "effort") {
- if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string")
- const current = session.model ?? (await defaultModel(this.config, session.cwd))
- const availableVariants = modelVariantsFromProviders(entries, current)
- if (!availableVariants.includes(params.value)) {
- throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` }))
- }
- this.sessionManager.setVariant(session.id, params.value)
- } else if (params.configId === "mode") {
- if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
- const availableModes = await this.loadAvailableModes(session.cwd)
- if (!availableModes.some((mode) => mode.id === params.value)) {
- throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` }))
- }
- this.sessionManager.setMode(session.id, params.value)
- } else {
- throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` }))
- }
-
- const updatedSession = this.sessionManager.get(session.id)
- const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
- const availableVariants = modelVariantsFromProviders(entries, model)
- const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false)
- const availableModels = buildAvailableModels(entries)
- const modeState = await this.resolveModeState(session.cwd, session.id)
- const modes = modeState.currentModeId
- ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
- : undefined
-
- return {
- configOptions: buildConfigOptions({
- currentModelId,
- availableModels,
- currentVariant: updatedSession.variant,
- availableVariants,
- modes,
- }),
- }
- }
-
- async prompt(params: PromptRequest) {
- const sessionID = params.sessionId
- const session = this.sessionManager.get(sessionID)
- const directory = session.cwd
-
- const current = session.model
- const model = current ?? (await defaultModel(this.config, directory))
- if (!current) {
- this.sessionManager.setModel(session.id, model)
- }
- const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name
-
- const parts: Array<
- | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
- | { type: "file"; url: string; filename: string; mime: string }
- > = []
- for (const part of params.prompt) {
- switch (part.type) {
- case "text":
- const audience = part.annotations?.audience
- const forAssistant = audience?.length === 1 && audience[0] === "assistant"
- const forUser = audience?.length === 1 && audience[0] === "user"
- parts.push({
- type: "text" as const,
- text: part.text,
- ...(forAssistant && { synthetic: true }),
- ...(forUser && { ignored: true }),
- })
- break
- case "image": {
- const parsed = parseUri(part.uri ?? "")
- const filename = parsed.type === "file" ? parsed.filename : "image"
- if (part.data) {
- parts.push({
- type: "file",
- url: `data:${part.mimeType};base64,${part.data}`,
- filename,
- mime: part.mimeType,
- })
- } else if (part.uri && part.uri.startsWith("http:")) {
- parts.push({
- type: "file",
- url: part.uri,
- filename,
- mime: part.mimeType,
- })
- }
- break
- }
-
- case "resource_link":
- const parsed = parseUri(part.uri)
- // Use the name from resource_link if available
- if (part.name && parsed.type === "file") {
- parsed.filename = part.name
- }
- parts.push(parsed)
-
- break
-
- case "resource": {
- const resource = part.resource
- if ("text" in resource && resource.text) {
- parts.push({
- type: "text",
- text: resource.text,
- })
- } else if ("blob" in resource && resource.blob && resource.mimeType) {
- // Binary resource (PDFs, etc.): store as file part with data URL
- const parsed = parseUri(resource.uri ?? "")
- const filename = parsed.type === "file" ? parsed.filename : "file"
- parts.push({
- type: "file",
- url: `data:${resource.mimeType};base64,${resource.blob}`,
- filename,
- mime: resource.mimeType,
- })
- }
- break
- }
-
- default:
- break
- }
- }
-
- log.info("parts", { parts })
-
- const cmd = (() => {
- const text = parts
- .filter((p): p is { type: "text"; text: string } => p.type === "text")
- .map((p) => p.text)
- .join("")
- .trim()
-
- if (!text.startsWith("/")) return
-
- const [name, ...rest] = text.slice(1).split(/\s+/)
- return { name, args: rest.join(" ").trim() }
- })()
-
- const buildUsage = (msg: AssistantMessage): Usage => ({
- totalTokens:
- msg.tokens.input +
- msg.tokens.output +
- msg.tokens.reasoning +
- (msg.tokens.cache?.read ?? 0) +
- (msg.tokens.cache?.write ?? 0),
- inputTokens: msg.tokens.input,
- outputTokens: msg.tokens.output,
- thoughtTokens: msg.tokens.reasoning || undefined,
- cachedReadTokens: msg.tokens.cache?.read || undefined,
- cachedWriteTokens: msg.tokens.cache?.write || undefined,
- })
-
- if (!cmd) {
- const response = await this.sdk.session.prompt({
- sessionID,
- model: {
- providerID: model.providerID,
- modelID: model.modelID,
- },
- variant: this.sessionManager.getVariant(sessionID),
- parts,
- agent,
- directory,
- })
- const msg = response.data?.info
-
- await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
-
- return {
- stopReason: "end_turn" as const,
- usage: msg ? buildUsage(msg) : undefined,
- _meta: {},
- }
- }
-
- const command = await this.config.sdk.command
- .list({ directory }, { throwOnError: true })
- .then((x) => x.data!.find((c) => c.name === cmd.name))
- if (command) {
- const response = await this.sdk.session.command({
- sessionID,
- command: command.name,
- arguments: cmd.args,
- model: model.providerID + "/" + model.modelID,
- agent,
- directory,
- })
- const msg = response.data?.info
-
- await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
-
- return {
- stopReason: "end_turn" as const,
- usage: msg ? buildUsage(msg) : undefined,
- _meta: {},
- }
- }
-
- switch (cmd.name) {
- case "compact":
- await this.config.sdk.session.summarize(
- {
- sessionID,
- directory,
- providerID: model.providerID,
- modelID: model.modelID,
- },
- { throwOnError: true },
- )
- break
- }
-
- await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
-
- return {
- stopReason: "end_turn" as const,
- _meta: {},
- }
- }
-
- async cancel(params: CancelNotification) {
- const session = this.sessionManager.get(params.sessionId)
- await this.config.sdk.session.abort(
- {
- sessionID: params.sessionId,
- directory: session.cwd,
- },
- { throwOnError: true },
- )
- }
-
- private async loadSessionMessages(directory: string, sessionId: string, limit?: number) {
- return this.sdk.session
- .messages(
- {
- sessionID: sessionId,
- directory,
- limit,
- },
- { throwOnError: true },
- )
- .then((x) => x.data)
- .catch((error) => {
- log.error("unexpected error when fetching message", { error })
- return undefined
- })
- }
-
- private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) {
- const lastUser = messages?.findLast((message) => message.info.role === "user")?.info
- if (lastUser?.role !== "user") return
-
- this.sessionManager.setModel(sessionId, {
- providerID: ProviderID.make(lastUser.model.providerID),
- modelID: ModelID.make(lastUser.model.modelID),
- })
- this.sessionManager.setVariant(sessionId, lastUser.model.variant)
- if (lastUser.agent) {
- this.sessionManager.setMode(sessionId, lastUser.agent)
- }
- }
-}
-
-function toToolKind(toolName: string): ToolKind {
- const tool = toolName.toLocaleLowerCase()
-
- switch (tool) {
- case ShellID.ToolID:
- return "execute"
-
- case "webfetch":
- return "fetch"
-
- case "edit":
- case "patch":
- case "write":
- return "edit"
-
- case "grep":
- case "glob":
- case "repo_clone":
- case "repo_overview":
- case "context7_resolve_library_id":
- case "context7_get_library_docs":
- return "search"
-
- case "read":
- return "read"
-
- default:
- return "other"
- }
-}
-
-function toLocations(toolName: string, input: Record): { path: string }[] {
- const tool = toolName.toLocaleLowerCase()
-
- switch (tool) {
- case "read":
- case "edit":
- case "write":
- return input["filePath"] ? [{ path: input["filePath"] }] : []
- case "glob":
- case "grep":
- return input["path"] ? [{ path: input["path"] }] : []
- case "repo_clone":
- return input["path"] ? [{ path: input["path"] }] : []
- case "repo_overview":
- return input["path"] ? [{ path: input["path"] }] : []
- case ShellID.ToolID:
- return []
- default:
- return []
- }
-}
-
-function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] {
- if (part.state.status !== "completed") return []
-
- const content: ToolCallContent[] = [
- {
- type: "content",
- content: {
- type: "text",
- text: part.state.output,
- },
- },
- ]
-
- if (kind === "edit") {
- const input = part.state.input
- const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
- const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
- const newText =
- typeof input["newString"] === "string"
- ? input["newString"]
- : typeof input["content"] === "string"
- ? input["content"]
- : ""
- content.push({
- type: "diff",
- path: filePath,
- oldText,
- newText,
- })
- }
-
- content.push(...imageContents(part.state.attachments ?? []))
- return content
-}
-
-function completedToolRawOutput(part: ToolPart) {
- if (part.state.status !== "completed") return {}
- return {
- output: part.state.output,
- metadata: part.state.metadata,
- ...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}),
+ cancel(params: CancelNotification) {
+ return run(this.service.cancel(params))
}
}
-function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] {
- return attachments.flatMap((attachment): ToolCallContent[] => {
- const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/)
- const mime = match?.[1] ?? attachment.mime
- if (!mime.startsWith("image/")) return []
- const data = match?.[2]
- if (data === undefined) return []
- return [
- {
- type: "content" as const,
- content: {
- type: "image" as const,
- mimeType: mime,
- data,
- },
- },
- ]
+function run(effect: Effect.Effect) {
+ return Effect.runPromise(effect.pipe(Effect.mapError(ACPError.toRequestError))).catch((defect: unknown) => {
+ if (defect instanceof RequestError) throw defect
+ throw ACPError.toRequestError(ACPError.fromUnknownDefect(defect))
})
}
-async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> {
- const sdk = config.sdk
- const configured = config.defaultModel
- if (configured) return configured
-
- const directory = cwd ?? process.cwd()
-
- const specified = await sdk.config
- .get({ directory }, { throwOnError: true })
- .then((resp) => {
- const cfg = resp.data
- if (!cfg || !cfg.model) return undefined
- return Provider.parseModel(cfg.model)
- })
- .catch((error) => {
- log.error("failed to load user config for default model", { error })
- return undefined
- })
-
- const providers = await sdk.config
- .providers({ directory }, { throwOnError: true })
- .then((x) => x.data?.providers ?? [])
- .catch((error) => {
- log.error("failed to list providers for default model", { error })
- return []
- })
-
- if (specified && providers.length) {
- const provider = providers.find((p) => p.id === specified.providerID)
- if (provider && provider.models[specified.modelID]) return specified
- }
-
- if (specified && !providers.length) return specified
-
- const lastUsed = await lastUsedModel(sdk, directory, providers)
- if (lastUsed) return lastUsed
-
- const opencodeProvider = providers.find((p) => p.id === "opencode")
- if (opencodeProvider) {
- const [best] = Provider.sort(Object.values(opencodeProvider.models))
- if (best) {
- return {
- providerID: ProviderID.make(best.providerID),
- modelID: ModelID.make(best.id),
- }
- }
- }
-
- const models = providers.flatMap((p) => Object.values(p.models))
- const [best] = Provider.sort(models)
- if (best) {
- return {
- providerID: ProviderID.make(best.providerID),
- modelID: ModelID.make(best.id),
- }
- }
-
- if (specified) return specified
- throw new Error("No models available")
-}
-
-async function lastUsedModel(
- sdk: OpencodeClient,
- directory: string,
- providers: Array<{ id: string; models: Record }>,
-): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> {
- const session = await sdk.session
- .list({ directory, roots: true, limit: 1 }, { throwOnError: true })
- .then((x) => x.data?.[0])
- .catch((error) => {
- log.error("failed to list sessions for default model", { error })
- return undefined
- })
- if (!session) return
-
- const lastUser = await sdk.session
- .messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true })
- .then((x) => x.data?.findLast((message) => message.info.role === "user")?.info)
- .catch((error) => {
- log.error("failed to load session messages for default model", { error, sessionID: session.id })
- return undefined
- })
- if (lastUser?.role !== "user") return
-
- const provider = providers.find((entry) => entry.id === lastUser.model.providerID)
- if (!provider?.models[lastUser.model.modelID]) return
- return {
- providerID: ProviderID.make(lastUser.model.providerID),
- modelID: ModelID.make(lastUser.model.modelID),
- }
-}
-
-function parseUri(
- uri: string,
-): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
- try {
- if (uri.startsWith("file://")) {
- const path = uri.slice(7)
- const name = path.split("/").pop() || path
- return {
- type: "file",
- url: uri,
- filename: name,
- mime: "text/plain",
- }
- }
- if (uri.startsWith("zed://")) {
- const url = new URL(uri)
- const path = url.searchParams.get("path")
- if (path) {
- const name = path.split("/").pop() || path
- return {
- type: "file",
- url: pathToFileURL(path).href,
- filename: name,
- mime: "text/plain",
- }
- }
- }
- return {
- type: "text",
- text: uri,
- }
- } catch {
- return {
- type: "text",
- text: uri,
- }
- }
-}
-
-function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
- const result = applyPatch(fileOriginal, unifiedDiff)
- if (result === false) {
- log.error("Failed to apply unified diff (context mismatch)")
- return undefined
- }
- return result
-}
-
-function sortProvidersByName(providers: T[]): T[] {
- return [...providers].sort((a, b) => {
- const nameA = a.name.toLowerCase()
- const nameB = b.name.toLowerCase()
- if (nameA < nameB) return -1
- if (nameA > nameB) return 1
- return 0
- })
-}
-
-function modelVariantsFromProviders(
- providers: Array<{ id: string; models: Record }> }>,
- model: { providerID: ProviderID; modelID: ModelID },
-): string[] {
- const provider = providers.find((entry) => entry.id === model.providerID)
- if (!provider) return []
- const modelInfo = provider.models[model.modelID]
- if (!modelInfo?.variants) return []
- return Object.keys(modelInfo.variants)
-}
-
-function buildAvailableModels(
- providers: Array<{ id: string; name: string; models: Record }>,
- options: { includeVariants?: boolean } = {},
-): ModelOption[] {
- const includeVariants = options.includeVariants ?? false
- return providers.flatMap((provider) => {
- const unsorted: Array<{ id: string; name: string; variants?: Record }> = Object.values(provider.models)
- const models = Provider.sort(unsorted)
- return models.flatMap((model) => {
- const base: ModelOption = {
- modelId: `${provider.id}/${model.id}`,
- name: `${provider.name}/${model.name}`,
- }
- if (!includeVariants || !model.variants) return [base]
- const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE)
- const variantOptions = variants.map((variant) => ({
- modelId: `${provider.id}/${model.id}/${variant}`,
- name: `${provider.name}/${model.name} (${variant})`,
- }))
- return [base, ...variantOptions]
- })
- })
-}
-
-function formatModelIdWithVariant(
- model: { providerID: ProviderID; modelID: ModelID },
- variant: string | undefined,
- availableVariants: string[],
- includeVariant: boolean,
-) {
- const base = `${model.providerID}/${model.modelID}`
- if (!includeVariant || availableVariants.length === 0) return base
- const selectedVariant =
- variant && availableVariants.includes(variant)
- ? variant
- : availableVariants.includes(DEFAULT_VARIANT_VALUE)
- ? DEFAULT_VARIANT_VALUE
- : availableVariants[0]
- return `${base}/${selectedVariant}`
-}
-
-function buildVariantMeta(input: {
- model: { providerID: ProviderID; modelID: ModelID }
- variant?: string
- availableVariants: string[]
-}) {
- return {
- opencode: {
- modelId: `${input.model.providerID}/${input.model.modelID}`,
- variant: input.variant ?? null,
- availableVariants: input.availableVariants,
- },
- }
-}
-
-function parseModelSelection(
- modelId: string,
- providers: Array<{ id: string; models: Record }> }>,
-): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } {
- const parsed = Provider.parseModel(modelId)
- const provider = providers.find((p) => p.id === parsed.providerID)
- if (!provider) {
- return { model: parsed, variant: undefined }
- }
-
- // Check if modelID exists directly
- if (provider.models[parsed.modelID]) {
- return { model: parsed, variant: undefined }
- }
-
- // Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high")
- const segments = parsed.modelID.split("/")
- if (segments.length > 1) {
- const candidateVariant = segments[segments.length - 1]
- const baseModelId = segments.slice(0, -1).join("/")
- const baseModelInfo = provider.models[baseModelId]
- if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) {
- return {
- model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) },
- variant: candidateVariant,
- }
- }
- }
-
- return { model: parsed, variant: undefined }
-}
-
-function buildConfigOptions(input: {
- currentModelId: string
- availableModels: ModelOption[]
- currentVariant?: string
- availableVariants?: string[]
- modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
-}): SessionConfigOption[] {
- const options: SessionConfigOption[] = [
- {
- id: "model",
- name: "Model",
- category: "model",
- type: "select",
- currentValue: input.currentModelId,
- options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
- },
- ]
- if (input.availableVariants?.length) {
- options.push({
- id: "effort",
- name: "Effort",
- description: "Available effort levels for this model",
- category: "thought_level",
- type: "select",
- currentValue:
- input.currentVariant && input.availableVariants.includes(input.currentVariant)
- ? input.currentVariant
- : input.availableVariants.includes(DEFAULT_VARIANT_VALUE)
- ? DEFAULT_VARIANT_VALUE
- : input.availableVariants[0],
- options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
- })
- }
- if (input.modes) {
- options.push({
- id: "mode",
- name: "Session Mode",
- category: "mode",
- type: "select",
- currentValue: input.modes.currentModeId,
- options: input.modes.availableModes.map((m) => ({
- value: m.id,
- name: m.name,
- ...(m.description ? { description: m.description } : {}),
- })),
- })
- }
- return options
-}
-
-function formatVariantName(variant: string) {
- return variant
- .split(/[_-]/)
- .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
- .join(" ")
-}
-
export * as ACP from "./agent"
diff --git a/packages/opencode/src/acp-next/config-option.ts b/packages/opencode/src/acp/config-option.ts
similarity index 100%
rename from packages/opencode/src/acp-next/config-option.ts
rename to packages/opencode/src/acp/config-option.ts
diff --git a/packages/opencode/src/acp-next/content.ts b/packages/opencode/src/acp/content.ts
similarity index 100%
rename from packages/opencode/src/acp-next/content.ts
rename to packages/opencode/src/acp/content.ts
diff --git a/packages/opencode/src/acp-next/directory.ts b/packages/opencode/src/acp/directory.ts
similarity index 93%
rename from packages/opencode/src/acp-next/directory.ts
rename to packages/opencode/src/acp/directory.ts
index 90ffa36358ff..c49613dd06fe 100644
--- a/packages/opencode/src/acp-next/directory.ts
+++ b/packages/opencode/src/acp/directory.ts
@@ -5,7 +5,7 @@ import { InstanceStore } from "@/project/instance-store"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
-import type * as ACPNextError from "./error"
+import type * as ACPError from "./error"
export type ModelOption = {
readonly providerID: ProviderID
@@ -39,18 +39,18 @@ export type Snapshot = {
}
export interface LoaderInterface {
- readonly load: (directory: string) => Effect.Effect
+ readonly load: (directory: string) => Effect.Effect
}
export interface Interface {
- readonly get: (directory: string) => Effect.Effect
- readonly refresh: (directory: string) => Effect.Effect
+ readonly get: (directory: string) => Effect.Effect
+ readonly refresh: (directory: string) => Effect.Effect
readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined
}
-export class Loader extends Context.Service()("@opencode/ACPNextDirectoryLoader") {}
+export class Loader extends Context.Service()("@opencode/ACPDirectoryLoader") {}
-export class Service extends Context.Service()("@opencode/ACPNextDirectory") {}
+export class Service extends Context.Service()("@opencode/ACPDirectory") {}
export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}`
@@ -110,7 +110,7 @@ export const loaderLayer = Layer.effect(
const command = yield* Command.Service
return Loader.of({
- load: Effect.fn("ACPNextDirectoryLoader.load")(function* (directory) {
+ load: Effect.fn("ACPDirectoryLoader.load")(function* (directory) {
const ctx = yield* store.load({ directory })
return yield* Effect.gen(function* () {
const providers = yield* provider.list()
@@ -142,7 +142,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const loader = yield* Loader
- const snapshots = yield* SynchronizedRef.make(new Map>())
+ const snapshots = yield* SynchronizedRef.make(new Map>())
const cached = Effect.fnUntraced(function* (directory: string) {
return yield* SynchronizedRef.modifyEffect(
@@ -166,11 +166,11 @@ export const layer = Layer.effect(
)
})
- const get = Effect.fn("ACPNextDirectory.get")(function* (directory: string) {
+ const get = Effect.fn("ACPDirectory.get")(function* (directory: string) {
return yield* yield* cached(directory)
})
- const refresh = Effect.fn("ACPNextDirectory.refresh")(function* (directory: string) {
+ const refresh = Effect.fn("ACPDirectory.refresh")(function* (directory: string) {
return yield* SynchronizedRef.modifyEffect(
snapshots,
Effect.fnUntraced(function* (items) {
diff --git a/packages/opencode/src/acp-next/error.ts b/packages/opencode/src/acp/error.ts
similarity index 77%
rename from packages/opencode/src/acp-next/error.ts
rename to packages/opencode/src/acp/error.ts
index 1d4af53b5030..119c2b775aea 100644
--- a/packages/opencode/src/acp-next/error.ts
+++ b/packages/opencode/src/acp/error.ts
@@ -2,51 +2,51 @@ import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
export class SessionNotFoundError extends Schema.TaggedErrorClass()(
- "ACPNextSessionNotFoundError",
+ "ACPSessionNotFoundError",
{
sessionId: Schema.String,
},
) {}
export class InvalidConfigOptionError extends Schema.TaggedErrorClass()(
- "ACPNextInvalidConfigOptionError",
+ "ACPInvalidConfigOptionError",
{
configId: Schema.String,
},
) {}
-export class InvalidModelError extends Schema.TaggedErrorClass()("ACPNextInvalidModelError", {
+export class InvalidModelError extends Schema.TaggedErrorClass()("ACPInvalidModelError", {
modelId: Schema.String,
providerId: Schema.optional(Schema.String),
}) {}
-export class InvalidEffortError extends Schema.TaggedErrorClass()("ACPNextInvalidEffortError", {
+export class InvalidEffortError extends Schema.TaggedErrorClass()("ACPInvalidEffortError", {
effort: Schema.String,
}) {}
-export class InvalidModeError extends Schema.TaggedErrorClass()("ACPNextInvalidModeError", {
+export class InvalidModeError extends Schema.TaggedErrorClass()("ACPInvalidModeError", {
mode: Schema.String,
}) {}
-export class AuthRequiredError extends Schema.TaggedErrorClass()("ACPNextAuthRequiredError", {
+export class AuthRequiredError extends Schema.TaggedErrorClass()("ACPAuthRequiredError", {
providerId: Schema.optional(Schema.String),
}) {}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass()(
- "ACPNextUnknownAuthMethodError",
+ "ACPUnknownAuthMethodError",
{
methodId: Schema.String,
},
) {}
export class UnsupportedOperationError extends Schema.TaggedErrorClass()(
- "ACPNextUnsupportedOperationError",
+ "ACPUnsupportedOperationError",
{
method: Schema.String,
},
) {}
-export class ServiceFailureError extends Schema.TaggedErrorClass()("ACPNextServiceFailureError", {
+export class ServiceFailureError extends Schema.TaggedErrorClass()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
}) {}
@@ -64,26 +64,26 @@ export type Error =
export function toRequestError(error: Error) {
switch (error._tag) {
- case "ACPNextSessionNotFoundError":
+ case "ACPSessionNotFoundError":
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
- case "ACPNextInvalidConfigOptionError":
+ case "ACPInvalidConfigOptionError":
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
- case "ACPNextInvalidModelError":
+ case "ACPInvalidModelError":
return RequestError.invalidParams(
{ providerId: error.providerId, modelId: error.modelId },
`model not found: ${error.modelId}`,
)
- case "ACPNextInvalidEffortError":
+ case "ACPInvalidEffortError":
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
- case "ACPNextInvalidModeError":
+ case "ACPInvalidModeError":
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
- case "ACPNextAuthRequiredError":
+ case "ACPAuthRequiredError":
return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required")
- case "ACPNextUnknownAuthMethodError":
+ case "ACPUnknownAuthMethodError":
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
- case "ACPNextUnsupportedOperationError":
+ case "ACPUnsupportedOperationError":
return RequestError.methodNotFound(error.method)
- case "ACPNextServiceFailureError":
+ case "ACPServiceFailureError":
return RequestError.internalError({ service: error.service }, error.safeMessage)
}
}
diff --git a/packages/opencode/src/acp-next/event.ts b/packages/opencode/src/acp/event.ts
similarity index 95%
rename from packages/opencode/src/acp-next/event.ts
rename to packages/opencode/src/acp/event.ts
index 9df7d8142d6a..df105d6acf77 100644
--- a/packages/opencode/src/acp-next/event.ts
+++ b/packages/opencode/src/acp/event.ts
@@ -10,8 +10,8 @@ import type {
ToolPart,
} from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
-import { ACPNextSession } from "./session"
-import { ACPNextPermission } from "./permission"
+import { ACPSession } from "./session"
+import { ACPPermission } from "./permission"
import {
duplicateRunningToolUpdate,
errorToolUpdate,
@@ -21,7 +21,7 @@ import {
completedToolUpdate,
} from "./tool"
-const log = Log.create({ service: "acp-next-event" })
+const log = Log.create({ service: "acp-event" })
type Connection = Pick &
Partial>
@@ -32,7 +32,7 @@ type GlobalEventStream = {
stream: AsyncIterable
}
-export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPNextSession.Interface }) {
+export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) {
const subscription = new Subscription(input)
subscription.start()
return subscription
@@ -42,17 +42,17 @@ export class Subscription {
private readonly abort = new AbortController()
private readonly shellSnapshots = new Map()
private readonly toolStarts = new Set()
- private readonly permission: ACPNextPermission.Handler
+ private readonly permission: ACPPermission.Handler
private started = false
constructor(
private readonly input: {
sdk: OpencodeClient
connection: Connection
- session: ACPNextSession.Interface
+ session: ACPSession.Interface
},
) {
- this.permission = new ACPNextPermission.Handler(input)
+ this.permission = new ACPPermission.Handler(input)
}
start() {
@@ -316,4 +316,4 @@ export class Subscription {
}
}
-export * as ACPNextEvent from "./event"
+export * as ACPEvent from "./event"
diff --git a/packages/opencode/src/acp-next/permission.ts b/packages/opencode/src/acp/permission.ts
similarity index 95%
rename from packages/opencode/src/acp-next/permission.ts
rename to packages/opencode/src/acp/permission.ts
index 73ebb38b1f72..cefd2a34f361 100644
--- a/packages/opencode/src/acp-next/permission.ts
+++ b/packages/opencode/src/acp/permission.ts
@@ -3,11 +3,11 @@ import * as Log from "@opencode-ai/core/util/log"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { exists, readText } from "@/util/filesystem"
-import type { ACPNextSession } from "./session"
+import type { ACPSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect"
-const log = Log.create({ service: "acp-next-permission" })
+const log = Log.create({ service: "acp-permission" })
type PermissionEvent = Extract
type Reply = "once" | "always" | "reject"
@@ -26,7 +26,7 @@ export class Handler {
private readonly input: {
sdk: OpencodeClient
connection: Connection
- session: ACPNextSession.Interface
+ session: ACPSession.Interface
},
) {}
@@ -142,4 +142,4 @@ function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
-export * as ACPNextPermission from "./permission"
+export * as ACPPermission from "./permission"
diff --git a/packages/opencode/src/acp-next/profile.ts b/packages/opencode/src/acp/profile.ts
similarity index 96%
rename from packages/opencode/src/acp-next/profile.ts
rename to packages/opencode/src/acp/profile.ts
index 4b79d6e903ae..9e728b6a1aad 100644
--- a/packages/opencode/src/acp-next/profile.ts
+++ b/packages/opencode/src/acp/profile.ts
@@ -39,4 +39,4 @@ function write(name: string, durationMs: number, fields?: Record(input: { directory: string; effect: Effect.Effect }) {
- const ctx = await InstanceRuntime.load({ directory: input.directory })
- return AppRuntime.runPromise(input.effect.pipe(Effect.provideService(InstanceRef, ctx)))
-}
-
-export const defaultAgentInfo = (directory: string) =>
- runDirectory({
- directory,
- effect: Agent.Service.use((svc) => svc.defaultInfo()),
- })
-
-export * as ACPRuntime from "./runtime"
diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp/service.ts
similarity index 88%
rename from packages/opencode/src/acp-next/service.ts
rename to packages/opencode/src/acp/service.ts
index 2f8147ef67c4..6b2b18219c9d 100644
--- a/packages/opencode/src/acp-next/service.ts
+++ b/packages/opencode/src/acp/service.ts
@@ -33,22 +33,22 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
-import * as ACPNextError from "./error"
+import * as ACPError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
import { promptContentToParts } from "./content"
import { Directory } from "./directory"
-import { ACPNextEvent } from "./event"
-import { ACPNextSession } from "./session"
+import { ACPEvent } from "./event"
+import { ACPSession } from "./session"
import { UsageService } from "./usage"
-import { ACPNextProfile } from "./profile"
+import { ACPProfile } from "./profile"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import type { Command } from "@/command"
export const AuthMethodID = "opencode-login"
-const log = Log.create({ service: "acp-next-service" })
+const log = Log.create({ service: "acp-service" })
-export type Error = ACPNextError.Error
+export type Error = ACPError.Error
type ServiceConnection = Pick &
Partial>
@@ -70,26 +70,26 @@ export type Interface = {
readonly cancel: (input: CancelNotification) => Effect.Effect
}
-export class Service extends Context.Service()("@opencode/ACPNext/Service") {}
+export class Service extends Context.Service()("@opencode/ACP/Service") {}
export function make(input: {
sdk: OpencodeClient
connection?: ServiceConnection
directory?: Directory.Interface
- session?: ACPNextSession.Interface
+ session?: ACPSession.Interface
usage?: UsageService.Interface
- eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
+ eventSubscription?: (subscription: ACPEvent.Subscription) => void
}): Interface {
const session = input.session ?? makeSessionService()
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map>()
const sessionSnapshots = new Map()
const events = input.connection
- ? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
+ ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session })
: undefined
if (events) input.eventSubscription?.(events)
- const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
+ const initialize = Effect.fn("ACP.initialize")(function* (params: InitializeRequest) {
const started = performance.now()
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
@@ -132,25 +132,25 @@ export function make(input: {
version: InstallationVersion,
},
}
- ACPNextProfile.duration("acp.initialize", started)
+ ACPProfile.duration("acp.initialize", started)
return response
})
- const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
+ const authenticate = Effect.fn("ACP.authenticate")(function* (params: AuthenticateRequest) {
if (params.methodId !== AuthMethodID) {
- return yield* new ACPNextError.UnknownAuthMethodError({ methodId: params.methodId })
+ return yield* new ACPError.UnknownAuthMethodError({ methodId: params.methodId })
}
return {}
})
- const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) {
+ const directorySnapshot = Effect.fn("ACP.directorySnapshot")(function* (cwd: string) {
const started = performance.now()
const snapshot = yield* directoryService.get(cwd)
- ACPNextProfile.duration("acp.directory.snapshot", started)
+ ACPProfile.duration("acp.directory.snapshot", started)
return snapshot
})
- const configSnapshot = Effect.fn("ACPNext.configSnapshot")(function* (state: ACPNextSession.Info) {
+ const configSnapshot = Effect.fn("ACP.configSnapshot")(function* (state: ACPSession.Info) {
const snapshot = sessionSnapshots.get(state.id)
if (snapshot) return snapshot
const loaded = yield* directorySnapshot(state.cwd)
@@ -158,7 +158,7 @@ export function make(input: {
return loaded
})
- const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
+ const newSession = Effect.fn("ACP.newSession")(function* (params: NewSessionRequest) {
const started = performance.now()
const snapshot = yield* directorySnapshot(params.cwd)
const selected = selectDefaultModel(snapshot)
@@ -202,11 +202,11 @@ export function make(input: {
modeId: state.modeId,
}),
}
- ACPNextProfile.duration("acp.newSession", started)
+ ACPProfile.duration("acp.newSession", started)
return response
})
- const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
+ const loadSession = Effect.fn("ACP.loadSession")(function* (params: LoadSessionRequest) {
const snapshot = yield* directorySnapshot(params.cwd)
yield* request(
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }),
@@ -245,7 +245,7 @@ export function make(input: {
}
})
- const listSessions = Effect.fn("ACPNext.listSessions")(function* (params: ListSessionsRequest) {
+ const listSessions = Effect.fn("ACP.listSessions")(function* (params: ListSessionsRequest) {
const cursor = params.cursor ? Number(params.cursor) : undefined
const limit = 100
const sessions = yield* request(
@@ -291,7 +291,7 @@ export function make(input: {
}
})
- const resumeSession = Effect.fn("ACPNext.resumeSession")(function* (params: ResumeSessionRequest) {
+ const resumeSession = Effect.fn("ACP.resumeSession")(function* (params: ResumeSessionRequest) {
const snapshot = yield* directorySnapshot(params.cwd)
yield* request(
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }),
@@ -330,7 +330,7 @@ export function make(input: {
}
})
- const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
+ const closeSession = Effect.fn("ACP.closeSession")(function* (params: CloseSessionRequest) {
const removed = yield* session.remove(params.sessionId)
registeredMcp.delete(params.sessionId)
sessionSnapshots.delete(params.sessionId)
@@ -349,7 +349,7 @@ export function make(input: {
return {}
})
- const forkSession = Effect.fn("ACPNext.forkSession")(function* (params: ForkSessionRequest) {
+ const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) {
const snapshot = yield* directorySnapshot(params.cwd)
const forked = yield* request(
() =>
@@ -393,13 +393,13 @@ export function make(input: {
}
})
- const setSessionConfigOption = Effect.fn("ACPNext.setSessionConfigOption")(function* (
+ const setSessionConfigOption = Effect.fn("ACP.setSessionConfigOption")(function* (
params: SetSessionConfigOptionRequest,
) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current)
if (typeof params.value !== "string") {
- return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
+ return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId })
}
if (params.configId === "model") {
@@ -421,7 +421,7 @@ export function make(input: {
const model = current.model ?? selectDefaultModel(snapshot)
const variants = Directory.variants(snapshot, model)
if (!variants || !Object.keys(variants).includes(params.value)) {
- return yield* new ACPNextError.InvalidEffortError({ effort: params.value })
+ return yield* new ACPError.InvalidEffortError({ effort: params.value })
}
const state = yield* session.setVariant(params.sessionId, params.value)
return {
@@ -435,7 +435,7 @@ export function make(input: {
if (params.configId === "mode") {
if (!snapshot.availableModes.some((mode) => mode.id === params.value)) {
- return yield* new ACPNextError.InvalidModeError({ mode: params.value })
+ return yield* new ACPError.InvalidModeError({ mode: params.value })
}
const state = yield* session.setMode(params.sessionId, params.value)
return {
@@ -447,20 +447,20 @@ export function make(input: {
}
}
- return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
+ return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId })
})
- const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
+ const setSessionMode = Effect.fn("ACP.setSessionMode")(function* (params: SetSessionModeRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current)
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
- return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
+ return yield* new ACPError.InvalidModeError({ mode: params.modeId })
}
yield* session.setMode(params.sessionId, params.modeId)
return {}
})
- const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
+ const setSessionModel = Effect.fn("ACP.setSessionModel")(function* (params: SetSessionModelRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current)
const selected = yield* parseSelectedModel(snapshot, params.modelId)
@@ -487,7 +487,7 @@ export function make(input: {
setSessionConfigOption,
setSessionMode,
setSessionModel,
- prompt: Effect.fn("ACPNext.prompt")(function* (params: PromptRequest) {
+ prompt: Effect.fn("ACP.prompt")(function* (params: PromptRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const selected = current.model ?? selectDefaultModel(snapshot)
@@ -563,15 +563,15 @@ export function make(input: {
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(undefined, params.messageId)
}),
- cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
- return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
+ cancel: Effect.fn("ACP.cancel")(function* (_input: CancelNotification) {
+ return yield* new ACPError.UnsupportedOperationError({ method: "session/cancel" })
}),
}
}
function makeSessionService() {
- return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
- ACPNextSession.Service.use((service) => Effect.succeed(service)),
+ return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
+ ACPSession.Service.use((service) => Effect.succeed(service)),
)
}
@@ -592,7 +592,7 @@ function makeDirectoryService(sdk: OpencodeClient) {
function makeUsageService(sdk: OpencodeClient) {
const limits = new Map>()
- const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")(
+ const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACP.promptUsage.contextLimit")(
function* (params) {
const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}`
const current = limits.get(key)
@@ -615,7 +615,7 @@ function makeUsageService(sdk: OpencodeClient) {
},
)
- const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")(
+ const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACP.promptUsage.sendUpdate")(
function* (params) {
const messages = yield* request(
() =>
@@ -675,7 +675,7 @@ function makeUsageService(sdk: OpencodeClient) {
})
}
-function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
+function replayMessages(subscription: ACPEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
if (!subscription) return Effect.void
return Effect.promise(async () => {
for (const message of messages) {
@@ -724,23 +724,23 @@ function request(fn: () => Promise>, service?: string) {
}
function profiledRequest(name: string, fn: () => Promise>, service?: string) {
- return request(() => ACPNextProfile.measure(name, fn), service)
+ return request(() => ACPProfile.measure(name, fn), service)
}
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
- return ACPNextProfile.measure("acp.directory.load", async () => {
+ return ACPProfile.measure("acp.directory.load", async () => {
const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([
- ACPNextProfile.measure("acp.directory.provider.list", () =>
+ ACPProfile.measure("acp.directory.provider.list", () =>
sdk.config.providers({ directory }, { throwOnError: true }),
),
- ACPNextProfile.measure("acp.directory.mode.defaultAgent.load", () =>
+ ACPProfile.measure("acp.directory.mode.defaultAgent.load", () =>
sdk.app.agents({ directory }, { throwOnError: true }),
),
- ACPNextProfile.measure("acp.directory.command.list", () =>
+ ACPProfile.measure("acp.directory.command.list", () =>
sdk.command.list({ directory }, { throwOnError: true }),
),
- ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
- ACPNextProfile.measure("acp.directory.defaultModel.config", () =>
+ ACPProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
+ ACPProfile.measure("acp.directory.defaultModel.config", () =>
sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined),
),
])
@@ -754,7 +754,7 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
>
const defaultModelStarted = performance.now()
const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers)
- ACPNextProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel })
+ ACPProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel })
const modes = agents
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
.map((agent) => ({
@@ -872,14 +872,14 @@ function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) {
const model = provider?.models[ModelID.make(selected.model.modelID)]
if (!model) {
return Effect.fail(
- new ACPNextError.InvalidModelError({
+ new ACPError.InvalidModelError({
providerId: selected.model.providerID,
modelId,
}),
)
}
if (selected.variant && !model.variants?.[selected.variant]) {
- return Effect.fail(new ACPNextError.InvalidEffortError({ effort: selected.variant }))
+ return Effect.fail(new ACPError.InvalidEffortError({ effort: selected.variant }))
}
return Effect.succeed({
model: {
@@ -954,7 +954,7 @@ function registerMcpServers(
).pipe(
Effect.tap(() =>
Effect.sync(() =>
- ACPNextProfile.duration("acp.mcp.register", started, {
+ ACPProfile.duration("acp.mcp.register", started, {
count: pending.size,
}),
),
@@ -1020,20 +1020,20 @@ function isSdkResponse(value: T | SdkResponse): value is SdkResponse {
}
function fromUnknownError(error: unknown, service?: string): Error {
- if (isACPNextError(error)) return error
+ if (isACPError(error)) return error
if (isAuthRequired(error)) {
- return new ACPNextError.AuthRequiredError({ providerId: findProviderID(error) })
+ return new ACPError.AuthRequiredError({ providerId: findProviderID(error) })
}
- return new ACPNextError.ServiceFailureError({ safeMessage: "OpenCode service failure", service })
+ return new ACPError.ServiceFailureError({ safeMessage: "OpenCode service failure", service })
}
-function isACPNextError(error: unknown): error is Error {
+function isACPError(error: unknown): error is Error {
return (
typeof error === "object" &&
error !== null &&
"_tag" in error &&
typeof error._tag === "string" &&
- error._tag.startsWith("ACPNext")
+ error._tag.startsWith("ACP")
)
}
diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts
index cc1ed0be3098..6b1c7eaf2b71 100644
--- a/packages/opencode/src/acp/session.ts
+++ b/packages/opencode/src/acp/session.ts
@@ -1,122 +1,232 @@
-import { RequestError, type McpServer } from "@agentclientprotocol/sdk"
-import type { ACPSessionState } from "./types"
-import * as Log from "@opencode-ai/core/util/log"
-import type { OpencodeClient } from "@opencode-ai/sdk/v2"
+import type { McpServer } from "@agentclientprotocol/sdk"
+import type { Message, Part } from "@opencode-ai/sdk/v2"
+import { Context, Effect, Layer, Ref } from "effect"
+import type { ModelID, ProviderID } from "../provider/schema"
+import * as ACPError from "./error"
+
+export type SelectedModel = {
+ providerID: ProviderID
+ modelID: ModelID
+}
-const log = Log.create({ service: "acp-session-manager" })
+export type KnownMessagePartMetadata = {
+ messageId: string
+ partId: string
+ partType?: Part["type"]
+ role?: Message["role"]
+ ignored?: boolean
+ toolCallId?: string
+ metadata?: unknown
+}
-export class ACPSessionManager {
- private sessions = new Map()
- private sdk: OpencodeClient
+export type Info = {
+ id: string
+ cwd: string
+ mcpServers: readonly McpServer[]
+ createdAt: Date
+ model?: SelectedModel
+ variant?: string
+ modeId?: string
+ knownParts: ReadonlyMap
+}
- constructor(sdk: OpencodeClient) {
- this.sdk = sdk
- }
+export type StoreInput = {
+ id: string
+ cwd: string
+ mcpServers?: readonly McpServer[]
+ createdAt?: Date
+ model?: SelectedModel
+ variant?: string
+ modeId?: string
+}
- tryGet(sessionId: string): ACPSessionState | undefined {
- return this.sessions.get(sessionId)
- }
+export type RecordPartMetadataInput = {
+ sessionId: string
+ messageId: string
+ partId: string
+ partType?: Part["type"]
+ role?: Message["role"]
+ ignored?: boolean
+ toolCallId?: string
+ metadata?: unknown
+}
- async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise {
- const session = await this.sdk.session
- .create(
- {
- directory: cwd,
- },
- { throwOnError: true },
- )
- .then((x) => x.data!)
-
- const sessionId = session.id
- const resolvedModel = model
-
- const state: ACPSessionState = {
- id: sessionId,
- cwd,
- mcpServers,
- createdAt: new Date(),
- model: resolvedModel,
- }
- log.info("creating_session", { state })
-
- this.sessions.set(sessionId, state)
- return state
- }
+export type PartMetadataLookupInput = {
+ sessionId: string
+ messageId: string
+ partId: string
+}
- async load(
+export type Interface = {
+ readonly create: (input: StoreInput) => Effect.Effect
+ readonly load: (input: StoreInput) => Effect.Effect
+ readonly list: (cwd?: string) => Effect.Effect
+ readonly get: (sessionId: string) => Effect.Effect
+ readonly tryGet: (sessionId: string) => Effect.Effect
+ readonly remove: (sessionId: string) => Effect.Effect
+ readonly setModel: (
sessionId: string,
- cwd: string,
- mcpServers: McpServer[],
- model?: ACPSessionState["model"],
- ): Promise {
- const session = await this.sdk.session
- .get(
- {
- sessionID: sessionId,
- directory: cwd,
- },
- { throwOnError: true },
- )
- .then((x) => x.data!)
-
- const resolvedModel = model
-
- const state: ACPSessionState = {
- id: sessionId,
- cwd,
- mcpServers,
- createdAt: new Date(session.time.created),
- model: resolvedModel,
- }
- log.info("loading_session", { state })
-
- this.sessions.set(sessionId, state)
- return state
- }
-
- get(sessionId: string): ACPSessionState {
- const session = this.sessions.get(sessionId)
- if (!session) {
- log.error("session not found", { sessionId })
- throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` }))
- }
- return session
- }
-
- getModel(sessionId: string) {
- const session = this.get(sessionId)
- return session.model
- }
-
- setModel(sessionId: string, model: ACPSessionState["model"]) {
- const session = this.get(sessionId)
- session.model = model
- this.sessions.set(sessionId, session)
- return session
- }
-
- getVariant(sessionId: string) {
- const session = this.get(sessionId)
- return session.variant
- }
+ model: SelectedModel | undefined,
+ ) => Effect.Effect
+ readonly getModel: (sessionId: string) => Effect.Effect
+ readonly setVariant: (
+ sessionId: string,
+ variant: string | undefined,
+ ) => Effect.Effect
+ readonly getVariant: (sessionId: string) => Effect.Effect
+ readonly setMode: (
+ sessionId: string,
+ modeId: string | undefined,
+ ) => Effect.Effect
+ readonly getMode: (sessionId: string) => Effect.Effect
+ readonly recordPartMetadata: (
+ input: RecordPartMetadataInput,
+ ) => Effect.Effect
+ readonly getPartMetadata: (
+ input: PartMetadataLookupInput,
+ ) => Effect.Effect
+ readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect
+}
- setVariant(sessionId: string, variant?: string) {
- const session = this.get(sessionId)
- session.variant = variant
- this.sessions.set(sessionId, session)
- return session
+export class Service extends Context.Service()("@opencode/ACP/Session") {}
+
+type State = Map
+
+export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const sessions = yield* Ref.make(new Map())
+
+ const store = Effect.fn("ACP.Session.store")(function* (input: StoreInput) {
+ const session = makeSession(input)
+ yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session))
+ return snapshot(session)
+ })
+
+ const tryGet = Effect.fn("ACP.Session.tryGet")(function* (sessionId: string) {
+ const session = (yield* Ref.get(sessions)).get(sessionId)
+ if (!session) return
+ return snapshot(session)
+ })
+
+ const get = Effect.fn("ACP.Session.get")(function* (sessionId: string) {
+ const session = yield* tryGet(sessionId)
+ if (session) return session
+ return yield* new ACPError.SessionNotFoundError({ sessionId })
+ })
+
+ const update = Effect.fn("ACP.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) {
+ const result = yield* Ref.modify(sessions, (state) => {
+ const session = state.get(sessionId)
+ if (!session) return [undefined, state] as const
+ const next = fn(session)
+ return [snapshot(next), new Map(state).set(sessionId, next)] as const
+ })
+ if (result) return result
+ return yield* new ACPError.SessionNotFoundError({ sessionId })
+ })
+
+ const remove = Effect.fn("ACP.Session.remove")(function* (sessionId: string) {
+ return yield* Ref.modify(sessions, (state) => {
+ const session = state.get(sessionId)
+ if (!session) return [undefined, state] as const
+ const next = new Map(state)
+ next.delete(sessionId)
+ return [snapshot(session), next] as const
+ })
+ })
+
+ const setModel: Interface["setModel"] = Effect.fn("ACP.Session.setModel")((sessionId, model) =>
+ update(sessionId, (session) => ({ ...session, model })),
+ )
+
+ const setVariant: Interface["setVariant"] = Effect.fn("ACP.Session.setVariant")((sessionId, variant) =>
+ update(sessionId, (session) => ({ ...session, variant })),
+ )
+
+ const setMode: Interface["setMode"] = Effect.fn("ACP.Session.setMode")((sessionId, modeId) =>
+ update(sessionId, (session) => ({ ...session, modeId })),
+ )
+
+ const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACP.Session.recordPartMetadata")((
+ input,
+ ) => {
+ const metadata = {
+ messageId: input.messageId,
+ partId: input.partId,
+ partType: input.partType,
+ role: input.role,
+ ignored: input.ignored,
+ toolCallId: input.toolCallId,
+ metadata: input.metadata,
+ }
+ return update(input.sessionId, (session) => ({
+ ...session,
+ knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata),
+ })).pipe(Effect.as(metadata))
+ })
+
+ return Service.of({
+ create: store,
+ load: store,
+ list: Effect.fn("ACP.Session.list")(function* (cwd?: string) {
+ return [...(yield* Ref.get(sessions)).values()]
+ .filter((session) => !cwd || session.cwd === cwd)
+ .map(snapshot)
+ .toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
+ }),
+ get,
+ tryGet,
+ remove,
+ setModel,
+ getModel: Effect.fn("ACP.Session.getModel")(function* (sessionId) {
+ return (yield* get(sessionId)).model
+ }),
+ setVariant,
+ getVariant: Effect.fn("ACP.Session.getVariant")(function* (sessionId) {
+ return (yield* get(sessionId)).variant
+ }),
+ setMode,
+ getMode: Effect.fn("ACP.Session.getMode")(function* (sessionId) {
+ return (yield* get(sessionId)).modeId
+ }),
+ recordPartMetadata,
+ getPartMetadata: Effect.fn("ACP.Session.getPartMetadata")(function* (input) {
+ return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input))
+ }),
+ tryGetPartMetadata: Effect.fn("ACP.Session.tryGetPartMetadata")(function* (input) {
+ return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input))
+ }),
+ })
+ }),
+)
+
+export const defaultLayer = layer
+
+function makeSession(input: StoreInput): Info {
+ return {
+ id: input.id,
+ cwd: input.cwd,
+ mcpServers: [...(input.mcpServers ?? [])],
+ createdAt: input.createdAt ? new Date(input.createdAt) : new Date(),
+ model: input.model,
+ variant: input.variant,
+ modeId: input.modeId,
+ knownParts: new Map(),
}
+}
- setMode(sessionId: string, modeId: string) {
- const session = this.get(sessionId)
- session.modeId = modeId
- this.sessions.set(sessionId, session)
- return session
+function snapshot(session: Info): Info {
+ return {
+ ...session,
+ mcpServers: [...session.mcpServers],
+ createdAt: new Date(session.createdAt),
+ knownParts: new Map(session.knownParts),
}
+}
- remove(sessionId: string): ACPSessionState | undefined {
- const session = this.sessions.get(sessionId)
- this.sessions.delete(sessionId)
- return session
- }
+function partMetadataKey(input: { messageId: string; partId: string }) {
+ return `${input.messageId}:${input.partId}`
}
+
+export * as ACPSession from "./session"
diff --git a/packages/opencode/src/acp-next/tool.ts b/packages/opencode/src/acp/tool.ts
similarity index 100%
rename from packages/opencode/src/acp-next/tool.ts
rename to packages/opencode/src/acp/tool.ts
diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts
deleted file mode 100644
index 2c3e886bc185..000000000000
--- a/packages/opencode/src/acp/types.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { McpServer } from "@agentclientprotocol/sdk"
-import type { OpencodeClient } from "@opencode-ai/sdk/v2"
-import type { ProviderID, ModelID } from "../provider/schema"
-
-export interface ACPSessionState {
- id: string
- cwd: string
- mcpServers: McpServer[]
- createdAt: Date
- model?: {
- providerID: ProviderID
- modelID: ModelID
- }
- variant?: string
- modeId?: string
-}
-
-export interface ACPConfig {
- sdk: OpencodeClient
- defaultModel?: {
- providerID: ProviderID
- modelID: ModelID
- }
-}
diff --git a/packages/opencode/src/acp-next/usage.ts b/packages/opencode/src/acp/usage.ts
similarity index 95%
rename from packages/opencode/src/acp-next/usage.ts
rename to packages/opencode/src/acp/usage.ts
index eabbb02af2e1..aae32466965e 100644
--- a/packages/opencode/src/acp-next/usage.ts
+++ b/packages/opencode/src/acp/usage.ts
@@ -7,7 +7,7 @@ import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
-const log = Log.create({ service: "acp-next-usage" })
+const log = Log.create({ service: "acp-usage" })
export type AssistantTokenCost = Pick
@@ -60,14 +60,14 @@ export interface Interface {
}
export class MessageLoader extends Context.Service()(
- "@opencode/ACPNextUsageMessageLoader",
+ "@opencode/ACPUsageMessageLoader",
) {}
export class ContextLimitLoader extends Context.Service()(
- "@opencode/ACPNextUsageContextLimitLoader",
+ "@opencode/ACPUsageContextLimitLoader",
) {}
-export class Service extends Context.Service()("@opencode/ACPNextUsage") {}
+export class Service extends Context.Service()("@opencode/ACPUsage") {}
export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface {
return MessageLoader.of({
@@ -124,7 +124,7 @@ export const contextLimitLoaderLayer = Layer.effect(
const provider = yield* Provider.Service
return ContextLimitLoader.of({
- providers: Effect.fn("ACPNextUsageContextLimitLoader.providers")(function* (directory) {
+ providers: Effect.fn("ACPUsageContextLimitLoader.providers")(function* (directory) {
const ctx = yield* store.load({ directory })
return yield* Effect.gen(function* () {
return yield* provider.list()
@@ -168,7 +168,7 @@ export const layer = Layer.effect(
)
})
- const contextLimit = Effect.fn("ACPNextUsage.contextLimit")(function* (input: {
+ const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: {
readonly directory: string
readonly providerID: ProviderID
readonly modelID: ModelID
@@ -176,7 +176,7 @@ export const layer = Layer.effect(
return yield* yield* cachedLimit(input)
})
- const sendUpdate = Effect.fn("ACPNextUsage.sendUpdate")(function* (input: {
+ const sendUpdate = Effect.fn("ACPUsage.sendUpdate")(function* (input: {
readonly connection: UsageConnection
readonly sessionID: string
readonly directory: string
diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts
index 35abfec7e5a9..99647e5e2a2a 100644
--- a/packages/opencode/src/cli/cmd/acp.ts
+++ b/packages/opencode/src/cli/cmd/acp.ts
@@ -3,13 +3,11 @@ import { Effect } from "effect"
import { effectCmd } from "../effect-cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent"
-import { ACPNext } from "@/acp-next/agent"
import { Server } from "@/server/server"
import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
-import { RuntimeFlags } from "@/effect/runtime-flags"
-import { ACPNextProfile } from "@/acp-next/profile"
+import { ACPProfile } from "@/acp/profile"
const log = Log.create({ service: "acp-command" })
@@ -24,13 +22,10 @@ export const AcpCommand = effectCmd({
})
},
handler: Effect.fn("Cli.acp")(function* (args) {
- ACPNextProfile.mark("cli.acp.handler")
+ ACPProfile.mark("cli.acp.handler")
process.env.OPENCODE_CLIENT = "acp"
- const flags = yield* RuntimeFlags.Service
const opts = yield* resolveNetworkOptions(args)
- const server = yield* Effect.promise(() =>
- ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)),
- )
+ const server = yield* Effect.promise(() => ACPProfile.measure("cli.acp.server.listen", () => Server.listen(opts)))
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
@@ -61,11 +56,11 @@ export const AcpCommand = effectCmd({
})
const stream = ndJsonStream(input, output)
- const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
+ const agent = ACP.init({ sdk })
new AgentSideConnection((conn) => {
- ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext })
- return agent.create(conn, { sdk })
+ ACPProfile.mark("cli.acp.connection.create")
+ return agent.create(conn)
}, stream)
log.info("setup connection")
diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts
index e0360ea72214..5765afee4e7b 100644
--- a/packages/opencode/src/effect/runtime-flags.ts
+++ b/packages/opencode/src/effect/runtime-flags.ts
@@ -50,7 +50,6 @@ export class Service extends ConfigService.Service()("@opencode/Runtime
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
- acpNext: bool("OPENCODE_ACP_NEXT"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
diff --git a/packages/opencode/test/acp/agent-interface.test.ts b/packages/opencode/test/acp/agent-interface.test.ts
deleted file mode 100644
index 7c4633d7d828..000000000000
--- a/packages/opencode/test/acp/agent-interface.test.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { ACP } from "../../src/acp/agent"
-import type { Agent as ACPAgent } from "@agentclientprotocol/sdk"
-
-/**
- * Type-level test: This line will fail to compile if ACP.Agent
- * doesn't properly implement the ACPAgent interface.
- *
- * The SDK checks for methods like `agent.unstable_setSessionModel` at runtime
- * and throws "Method not found" if they're missing. TypeScript allows optional
- * interface methods to be omitted, but the SDK still expects them.
- *
- * @see https://github.com/agentclientprotocol/typescript-sdk/commit/7072d3f
- */
-type _AssertAgentImplementsACPAgent = ACP.Agent extends ACPAgent ? true : never
-const _typeCheck: _AssertAgentImplementsACPAgent = true
-
-/**
- * Runtime verification that optional methods the SDK expects are actually implemented.
- * The SDK's router checks `if (!agent.methodName)` and throws MethodNotFound if missing.
- */
-describe("acp.agent interface compliance", () => {
- // Extract method names from the ACPAgent interface type
- type ACPAgentMethods = keyof ACPAgent
-
- // Methods that the SDK's router explicitly checks for at runtime
- const sdkCheckedMethods: ACPAgentMethods[] = [
- // Required
- "initialize",
- "newSession",
- "prompt",
- "cancel",
- // Optional but checked by SDK router
- "loadSession",
- "setSessionMode",
- "authenticate",
- // Capability-gated methods checked by the SDK router
- "listSessions",
- "resumeSession",
- "closeSession",
- "unstable_forkSession",
- "unstable_setSessionModel",
- ]
-
- test("Agent implements all SDK-checked methods", () => {
- for (const method of sdkCheckedMethods) {
- expect(typeof ACP.Agent.prototype[method as keyof typeof ACP.Agent.prototype], `Missing method: ${method}`).toBe(
- "function",
- )
- }
- })
-})
diff --git a/packages/opencode/test/acp-next/config-option.test.ts b/packages/opencode/test/acp/config-option.test.ts
similarity index 98%
rename from packages/opencode/test/acp-next/config-option.test.ts
rename to packages/opencode/test/acp/config-option.test.ts
index d538ee81c01a..846cfadad67e 100644
--- a/packages/opencode/test/acp-next/config-option.test.ts
+++ b/packages/opencode/test/acp/config-option.test.ts
@@ -8,7 +8,7 @@ import {
formatVariantName,
parseModelSelection,
type ConfigOptionProvider,
-} from "@/acp-next/config-option"
+} from "@/acp/config-option"
const providers: ConfigOptionProvider[] = [
{
@@ -46,7 +46,7 @@ const providers: ConfigOptionProvider[] = [
},
]
-describe("acp-next config options", () => {
+describe("acp config options", () => {
test("builds the model select option with ACP verifier category", () => {
expect(
buildModelSelectOption({
diff --git a/packages/opencode/test/acp-next/content.test.ts b/packages/opencode/test/acp/content.test.ts
similarity index 97%
rename from packages/opencode/test/acp-next/content.test.ts
rename to packages/opencode/test/acp/content.test.ts
index 88f8608345b1..90f62f9d1892 100644
--- a/packages/opencode/test/acp-next/content.test.ts
+++ b/packages/opencode/test/acp/content.test.ts
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { ContentBlock } from "@agentclientprotocol/sdk"
import { pathToFileURL } from "node:url"
-import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp-next/content"
+import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
-describe("acp-next content conversion", () => {
+describe("acp content conversion", () => {
test("plain text block becomes a text part", () => {
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
})
@@ -158,7 +158,7 @@ describe("acp-next content conversion", () => {
})
})
-describe("acp-next replay conversion", () => {
+describe("acp replay conversion", () => {
test("replays text audience annotations", () => {
expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
{
diff --git a/packages/opencode/test/acp-next/directory.test.ts b/packages/opencode/test/acp/directory.test.ts
similarity index 98%
rename from packages/opencode/test/acp-next/directory.test.ts
rename to packages/opencode/test/acp/directory.test.ts
index 050ff06d45bb..aa5fc12df2ba 100644
--- a/packages/opencode/test/acp-next/directory.test.ts
+++ b/packages/opencode/test/acp/directory.test.ts
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
-import { Directory } from "@/acp-next/directory"
+import { Directory } from "@/acp/directory"
import { Command } from "@/command"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
@@ -97,7 +97,7 @@ const fakeLayer = (calls: string[]) =>
),
)
-describe("ACP next directory snapshot", () => {
+describe("ACP directory snapshot", () => {
it.effect("two concurrent callers share one load", () => {
const calls: string[] = []
return Effect.gen(function* () {
diff --git a/packages/opencode/test/acp-next/error.test.ts b/packages/opencode/test/acp/error.test.ts
similarity index 53%
rename from packages/opencode/test/acp-next/error.test.ts
rename to packages/opencode/test/acp/error.test.ts
index a82c6c576e11..f96ab2823dbb 100644
--- a/packages/opencode/test/acp-next/error.test.ts
+++ b/packages/opencode/test/acp/error.test.ts
@@ -1,35 +1,35 @@
import { describe, expect, test } from "bun:test"
import { RequestError } from "@agentclientprotocol/sdk"
-import * as ACPNextError from "../../src/acp-next/error"
+import * as ACPError from "../../src/acp/error"
-describe("acp-next.error", () => {
+describe("acp.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" }),
+ const cases: ACPError.Error[] = [
+ new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
+ new ACPError.InvalidConfigOptionError({ configId: "temperature" }),
+ new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
+ new ACPError.InvalidEffortError({ effort: "extreme" }),
+ new ACPError.InvalidModeError({ mode: "turbo" }),
]
- expect(cases.map((error) => ACPNextError.toRequestError(error).code)).toEqual([
+ expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([
-32602, -32602, -32602, -32602, -32602,
])
})
test("includes safe validation details", () => {
- expect(ACPNextError.toRequestError(new ACPNextError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
+ expect(ACPError.toRequestError(new ACPError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
code: -32602,
data: { sessionId: "ses_123" },
})
- expect(ACPNextError.toRequestError(new ACPNextError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
+ expect(ACPError.toRequestError(new ACPError.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" }))
+ const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError({ providerId: "anthropic" }))
expect(requestError).toBeInstanceOf(RequestError)
expect(requestError.code).toBe(-32000)
@@ -38,8 +38,8 @@ describe("acp-next.error", () => {
})
test("maps unsupported operations to method not found", () => {
- const requestError = ACPNextError.toRequestError(
- new ACPNextError.UnsupportedOperationError({ method: "session/new" }),
+ const requestError = ACPError.toRequestError(
+ new ACPError.UnsupportedOperationError({ method: "session/new" }),
)
expect(requestError.code).toBe(-32601)
@@ -47,8 +47,8 @@ describe("acp-next.error", () => {
})
test("maps service failures to safe internal errors", () => {
- const requestError = ACPNextError.toRequestError(
- new ACPNextError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
+ const requestError = ACPError.toRequestError(
+ new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
)
expect(requestError.code).toBe(-32603)
@@ -57,8 +57,8 @@ describe("acp-next.error", () => {
})
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 requestError = ACPError.toRequestError(
+ ACPError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")),
)
const serialized = JSON.stringify(requestError.toErrorResponse())
diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts
deleted file mode 100644
index a01680e30596..000000000000
--- a/packages/opencode/test/acp/event-subscription.test.ts
+++ /dev/null
@@ -1,977 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { ACP } from "../../src/acp/agent"
-import type { AgentSideConnection } from "@agentclientprotocol/sdk"
-import type {
- Event,
- EventMessagePartUpdated,
- ToolStateCompleted,
- ToolStatePending,
- ToolStateRunning,
-} from "@opencode-ai/sdk/v2"
-import { provideTestInstance, tmpdir } from "../fixture/fixture"
-
-const pollUntil = async (
- check: () => T | undefined | false | Promise,
- message: string,
- opts?: { timeoutMs?: number; intervalMs?: number },
-): Promise => {
- const timeoutMs = opts?.timeoutMs ?? 2000
- const intervalMs = opts?.intervalMs ?? 5
- const started = Date.now()
- while (true) {
- const v = await check()
- if (v !== undefined && v !== null && v !== false) return v as T
- if (Date.now() - started > timeoutMs) throw new Error(message)
- await new Promise((r) => setTimeout(r, intervalMs))
- }
-}
-
-type SessionUpdateParams = Parameters[0]
-type RequestPermissionParams = Parameters[0]
-type RequestPermissionResult = Awaited>
-
-type GlobalEventEnvelope = {
- directory?: string
- payload?: Event
-}
-
-type EventController = {
- push: (event: GlobalEventEnvelope) => void
- close: () => void
-}
-
-function inProgressText(update: SessionUpdateParams["update"]) {
- if (update.sessionUpdate !== "tool_call_update") return undefined
- if (update.status !== "in_progress") return undefined
- if (!update.content || !Array.isArray(update.content)) return undefined
- const first = update.content[0]
- if (!first || first.type !== "content") return undefined
- if (first.content.type !== "text") return undefined
- return first.content.text
-}
-
-function isToolCallUpdate(
- update: SessionUpdateParams["update"],
-): update is Extract {
- return update.sessionUpdate === "tool_call_update"
-}
-
-function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) {
- return sessionUpdates
- .filter((u) => u.sessionId === sessionId)
- .map((u) => u.update)
- .filter(isToolCallUpdate)
- .find((u) => u.toolCallId === callID && u.status === "completed")
-}
-
-function toolEvent(
- sessionId: string,
- cwd: string,
- opts: {
- callID: string
- tool: string
- input: Record
- } & ({ status: "running"; metadata?: Record } | { status: "pending"; raw: string }),
-): GlobalEventEnvelope {
- const state: ToolStatePending | ToolStateRunning =
- opts.status === "running"
- ? {
- status: "running",
- input: opts.input,
- ...(opts.metadata && { metadata: opts.metadata }),
- time: { start: Date.now() },
- }
- : {
- status: "pending",
- input: opts.input,
- raw: opts.raw,
- }
- const payload: EventMessagePartUpdated = {
- id: `evt_${opts.callID}`,
- type: "message.part.updated",
- properties: {
- sessionID: sessionId,
- time: Date.now(),
- part: {
- id: `part_${opts.callID}`,
- sessionID: sessionId,
- messageID: `msg_${opts.callID}`,
- type: "tool",
- callID: opts.callID,
- tool: opts.tool,
- state,
- },
- },
- }
- return { directory: cwd, payload }
-}
-
-function completedToolEvent(
- sessionId: string,
- cwd: string,
- opts: {
- callID: string
- tool: string
- input: Record
- output: string
- attachments?: ToolStateCompleted["attachments"]
- },
-): GlobalEventEnvelope {
- const state: ToolStateCompleted = {
- status: "completed",
- input: opts.input,
- output: opts.output,
- title: opts.tool,
- metadata: {},
- time: { start: Date.now() - 1, end: Date.now() },
- ...(opts.attachments && { attachments: opts.attachments }),
- }
- const payload: EventMessagePartUpdated = {
- id: `evt_${opts.callID}`,
- type: "message.part.updated",
- properties: {
- sessionID: sessionId,
- time: Date.now(),
- part: {
- id: `part_${opts.callID}`,
- sessionID: sessionId,
- messageID: `msg_${opts.callID}`,
- type: "tool",
- callID: opts.callID,
- tool: opts.tool,
- state,
- },
- },
- }
- return { directory: cwd, payload }
-}
-
-function createEventStream() {
- const queue: GlobalEventEnvelope[] = []
- const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
- const state = { closed: false }
-
- const push = (event: GlobalEventEnvelope) => {
- const waiter = waiters.shift()
- if (waiter) {
- waiter(event)
- return
- }
- queue.push(event)
- }
-
- const close = () => {
- state.closed = true
- for (const waiter of waiters.splice(0)) {
- waiter(undefined)
- }
- }
-
- const stream = async function* (signal?: AbortSignal) {
- while (true) {
- if (signal?.aborted) return
- const next = queue.shift()
- if (next) {
- yield next
- continue
- }
- if (state.closed) return
- const value = await new Promise((resolve) => {
- waiters.push(resolve)
- if (!signal) return
- signal.addEventListener("abort", () => resolve(undefined), { once: true })
- })
- if (!value) return
- yield value
- }
- }
-
- return { controller: { push, close } satisfies EventController, stream }
-}
-
-function createFakeAgent() {
- const updates = new Map()
- const chunks = new Map()
- const sessionUpdates: SessionUpdateParams[] = []
- const record = (sessionId: string, type: string) => {
- const list = updates.get(sessionId) ?? []
- list.push(type)
- updates.set(sessionId, list)
- }
-
- const connection = {
- async sessionUpdate(params: SessionUpdateParams) {
- sessionUpdates.push(params)
- const update = params.update
- const type = update?.sessionUpdate ?? "unknown"
- record(params.sessionId, type)
- if (update?.sessionUpdate === "agent_message_chunk") {
- const content = update.content
- if (content?.type !== "text") return
- if (typeof content.text !== "string") return
- chunks.set(params.sessionId, (chunks.get(params.sessionId) ?? "") + content.text)
- }
- },
- async requestPermission(_params: RequestPermissionParams): Promise {
- return { outcome: { outcome: "selected", optionId: "once" } } as RequestPermissionResult
- },
- } as unknown as AgentSideConnection
-
- const { controller, stream } = createEventStream()
- const calls = {
- eventSubscribe: 0,
- sessionCreate: 0,
- }
-
- const sdk = {
- global: {
- event: async (opts?: { signal?: AbortSignal }) => {
- calls.eventSubscribe++
- return { stream: stream(opts?.signal) }
- },
- },
- session: {
- create: async (_params?: any) => {
- calls.sessionCreate++
- return {
- data: {
- id: `ses_${calls.sessionCreate}`,
- time: { created: new Date().toISOString() },
- },
- }
- },
- get: async (_params?: any) => {
- return {
- data: {
- id: "ses_1",
- time: { created: new Date().toISOString() },
- },
- }
- },
- messages: async () => {
- return { data: [] }
- },
- message: async (params?: any) => {
- // Return a message with parts that can be looked up by partID
- return {
- data: {
- info: {
- role: "assistant",
- },
- parts: [
- {
- id: params?.messageID ? `${params.messageID}_part` : "part_1",
- type: "text",
- text: "",
- },
- ],
- },
- }
- },
- },
- permission: {
- respond: async () => {
- return { data: true }
- },
- },
- config: {
- providers: async () => {
- return {
- data: {
- providers: [
- {
- id: "opencode",
- name: "opencode",
- models: {
- "big-pickle": { id: "big-pickle", name: "big-pickle" },
- },
- },
- ],
- },
- }
- },
- },
- app: {
- agents: async () => {
- return {
- data: [
- {
- name: "build",
- description: "build",
- mode: "agent",
- },
- ],
- }
- },
- },
- command: {
- list: async () => {
- return { data: [] }
- },
- },
- mcp: {
- add: async () => {
- return { data: true }
- },
- },
- } as any
-
- const agent = new ACP.Agent(connection, {
- sdk,
- defaultModel: { providerID: "opencode", modelID: "big-pickle" },
- } as any)
-
- const stop = () => {
- controller.close()
- ;(agent as any).eventAbort.abort()
- }
-
- return { agent, controller, calls, updates, chunks, sessionUpdates, stop, sdk, connection }
-}
-
-describe("acp.agent event subscription", () => {
- test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const { agent, controller, updates, stop } = createFakeAgent()
- const cwd = "/tmp/opencode-acp-test"
-
- const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
- const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
-
- controller.push({
- directory: cwd,
- payload: {
- type: "message.part.delta",
- properties: {
- sessionID: sessionB,
- messageID: "msg_1",
- partID: "msg_1_part",
- field: "text",
- delta: "hello",
- },
- },
- } as any)
-
- await pollUntil(
- () => (updates.get(sessionB) ?? []).includes("agent_message_chunk"),
- "sessionB never received agent_message_chunk",
- )
-
- expect((updates.get(sessionA) ?? []).includes("agent_message_chunk")).toBe(false)
- expect((updates.get(sessionB) ?? []).includes("agent_message_chunk")).toBe(true)
-
- stop()
- },
- })
- })
-
- test("does not emit user_message_chunk for live prompt parts", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const { agent, controller, sessionUpdates, stop } = createFakeAgent()
- const cwd = "/tmp/opencode-acp-test"
- const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
-
- controller.push({
- directory: cwd,
- payload: {
- type: "message.part.updated",
- properties: {
- sessionID: sessionId,
- time: Date.now(),
- part: {
- id: "part_1",
- sessionID: sessionId,
- messageID: "msg_user",
- type: "text",
- text: "hello",
- },
- },
- },
- } as any)
-
- controller.push({
- directory: cwd,
- payload: {
- type: "message.part.delta",
- properties: {
- sessionID: sessionId,
- messageID: "msg_marker",
- partID: "msg_marker_part",
- field: "text",
- delta: "marker",
- },
- },
- } as any)
-
- await pollUntil(
- () =>
- sessionUpdates.some((u) => u.sessionId === sessionId && u.update.sessionUpdate === "agent_message_chunk"),
- "marker event was never processed",
- )
-
- expect(
- sessionUpdates
- .filter((u) => u.sessionId === sessionId)
- .some((u) => u.update.sessionUpdate === "user_message_chunk"),
- ).toBe(false)
-
- stop()
- },
- })
- })
-
- test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const { agent, controller, chunks, stop } = createFakeAgent()
- const cwd = "/tmp/opencode-acp-test"
-
- const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
- const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
-
- const tokenA = ["ALPHA_", "111", "_X"]
- const tokenB = ["BETA_", "222", "_Y"]
-
- const push = (sessionId: string, messageID: string, delta: string) => {
- controller.push({
- directory: cwd,
- payload: {
- type: "message.part.delta",
- properties: {
- sessionID: sessionId,
- messageID,
- partID: `${messageID}_part`,
- field: "text",
- delta,
- },
- },
- } as any)
- }
-
- push(sessionA, "msg_a", tokenA[0])
- push(sessionB, "msg_b", tokenB[0])
- push(sessionA, "msg_a", tokenA[1])
- push(sessionB, "msg_b", tokenB[1])
- push(sessionA, "msg_a", tokenA[2])
- push(sessionB, "msg_b", tokenB[2])
-
- await pollUntil(
- () =>
- (chunks.get(sessionA) ?? "").includes(tokenA.join("")) &&
- (chunks.get(sessionB) ?? "").includes(tokenB.join("")),
- "interleaved chunks never fully arrived",
- )
-
- const a = chunks.get(sessionA) ?? ""
- const b = chunks.get(sessionB) ?? ""
-
- expect(a).toContain(tokenA.join(""))
- expect(b).toContain(tokenB.join(""))
- for (const part of tokenB) expect(a).not.toContain(part)
- for (const part of tokenA) expect(b).not.toContain(part)
-
- stop()
- },
- })
- })
-
- test("does not create additional event subscriptions on repeated loadSession()", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const { agent, calls, stop } = createFakeAgent()
- const cwd = "/tmp/opencode-acp-test"
-
- const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
-
- await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
- await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
- await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
- await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
-
- expect(calls.eventSubscribe).toBe(1)
-
- stop()
- },
- })
- })
-
- test("permission.asked events are handled and replied", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const permissionReplies: string[] = []
- const { agent, controller, stop, sdk } = createFakeAgent()
- sdk.permission.reply = async (params: any) => {
- permissionReplies.push(params.requestID)
- return { data: true }
- }
- const cwd = "/tmp/opencode-acp-test"
-
- const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
-
- controller.push({
- directory: cwd,
- payload: {
- type: "permission.asked",
- properties: {
- id: "perm_1",
- sessionID: sessionA,
- permission: "bash",
- patterns: ["*"],
- metadata: {},
- always: [],
- },
- },
- } as any)
-
- await pollUntil(() => permissionReplies.includes("perm_1"), "perm_1 was never replied")
-
- expect(permissionReplies).toContain("perm_1")
-
- stop()
- },
- })
- })
-
- test("permission prompt on session A does not block message updates for session B", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const permissionReplies: string[] = []
- let resolvePermissionA: (() => void) | undefined
- const permissionABlocking = new Promise((r) => {
- resolvePermissionA = r
- })
-
- const { agent, controller, chunks, stop, sdk, connection } = createFakeAgent()
-
- // Make permission request for session A block until we release it
- const originalRequestPermission = connection.requestPermission.bind(connection)
- let _permissionCalls = 0
- connection.requestPermission = async (params: RequestPermissionParams) => {
- _permissionCalls++
- if (params.sessionId.endsWith("1")) {
- await permissionABlocking
- }
- return originalRequestPermission(params)
- }
-
- sdk.permission.reply = async (params: any) => {
- permissionReplies.push(params.requestID)
- return { data: true }
- }
-
- const cwd = "/tmp/opencode-acp-test"
-
- const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
- const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
-
- // Push permission.asked for session A (will block)
- controller.push({
- directory: cwd,
- payload: {
- type: "permission.asked",
- properties: {
- id: "perm_a",
- sessionID: sessionA,
- permission: "bash",
- patterns: ["*"],
- metadata: {},
- always: [],
- },
- },
- } as any)
-
- await pollUntil(() => _permissionCalls > 0, "permission handling for A never started")
-
- controller.push({
- directory: cwd,
- payload: {
- type: "message.part.delta",
- properties: {
- sessionID: sessionB,
- messageID: "msg_b",
- partID: "msg_b_part",
- field: "text",
- delta: "session_b_message",
- },
- },
- } as any)
-
- await pollUntil(
- () => (chunks.get(sessionB) ?? "").includes("session_b_message"),
- "session B never received its message",
- )
-
- expect(chunks.get(sessionB) ?? "").toContain("session_b_message")
- expect(permissionReplies).not.toContain("perm_a")
-
- resolvePermissionA!()
- await pollUntil(() => permissionReplies.includes("perm_a"), "perm_a was never replied after release")
-
- expect(permissionReplies).toContain("perm_a")
-
- stop()
- },
- })
- })
-
- test("streams running bash output snapshots and de-dupes identical snapshots", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const { agent, controller, sessionUpdates, stop } = createFakeAgent()
- const cwd = "/tmp/opencode-acp-test"
- const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
- const input = { command: "echo hello", description: "run command" }
-
- for (const output of ["a", "a", "ab"]) {
- controller.push(
- toolEvent(sessionId, cwd, {
- callID: "call_1",
- tool: "bash",
- status: "running",
- input,
- metadata: { output },
- }),
- )
- }
- await pollUntil(
- () =>
- sessionUpdates
- .filter((u) => u.sessionId === sessionId)
- .filter((u) => isToolCallUpdate(u.update))
- .map((u) => inProgressText(u.update))
- .filter((t) => t === "ab").length > 0,
- "final bash snapshot 'ab' never arrived",
- )
-
- const snapshots = sessionUpdates
- .filter((u) => u.sessionId === sessionId)
- .filter((u) => isToolCallUpdate(u.update))
- .map((u) => inProgressText(u.update))
-
- expect(snapshots).toEqual(["a", undefined, "ab"])
- stop()
- },
- })
- })
-
- test("emits synthetic pending before first running update for any tool", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const { agent, controller, sessionUpdates, stop } = createFakeAgent()
- const cwd = "/tmp/opencode-acp-test"
- const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
-
- controller.push(
- toolEvent(sessionId, cwd, {
- callID: "call_bash",
- tool: "bash",
- status: "running",
- input: { command: "echo hi", description: "run command" },
- metadata: { output: "hi\n" },
- }),
- )
- controller.push(
- toolEvent(sessionId, cwd, {
- callID: "call_read",
- tool: "read",
- status: "running",
- input: { filePath: "/tmp/example.txt" },
- }),
- )
- await pollUntil(
- () =>
- sessionUpdates
- .filter((u) => u.sessionId === sessionId)
- .map((u) => u.update.sessionUpdate)
- .filter((u) => u === "tool_call" || u === "tool_call_update").length >= 4,
- "expected 4 tool_call/tool_call_update events",
- )
-
- const types = sessionUpdates
- .filter((u) => u.sessionId === sessionId)
- .map((u) => u.update.sessionUpdate)
- .filter((u) => u === "tool_call" || u === "tool_call_update")
- expect(types).toEqual(["tool_call", "tool_call_update", "tool_call", "tool_call_update"])
-
- const pendings = sessionUpdates.filter(
- (u) => u.sessionId === sessionId && u.update.sessionUpdate === "tool_call",
- )
- expect(pendings.every((p) => p.update.sessionUpdate === "tool_call" && p.update.status === "pending")).toBe(
- true,
- )
- stop()
- },
- })
- })
-
- test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const { agent, controller, sessionUpdates, stop } = createFakeAgent()
- const cwd = "/tmp/opencode-acp-test"
- const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
- const data = Buffer.from("image-data").toString("base64")
-
- controller.push(
- completedToolEvent(sessionId, cwd, {
- callID: "call_image",
- tool: "read",
- input: { filePath: "/tmp/image.png" },
- output: "Image read successfully",
- attachments: [
- {
- id: "part_image",
- sessionID: sessionId,
- messageID: "msg_image",
- type: "file",
- mime: "image/png",
- filename: "image.png",
- url: `data:image/png;base64,${data}`,
- },
- {
- id: "part_text",
- sessionID: sessionId,
- messageID: "msg_image",
- type: "file",
- mime: "text/plain",
- filename: "note.txt",
- url: "data:text/plain;base64,Zm9v",
- },
- ],
- }),
- )
- await pollUntil(
- () => completedToolUpdate(sessionUpdates, sessionId, "call_image"),
- "completed tool update for call_image never arrived",
- )
-
- const update = completedToolUpdate(sessionUpdates, sessionId, "call_image")
- expect(update?.content).toContainEqual({
- type: "content",
- content: { type: "text", text: "Image read successfully" },
- })
- expect(update?.content).toContainEqual({
- type: "content",
- content: { type: "image", mimeType: "image/png", data },
- })
- expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false)
- expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2)
-
- stop()
- },
- })
- })
-
- test("replays completed tool image attachments as ACP tool content blocks", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const { agent, sessionUpdates, stop, sdk } = createFakeAgent()
- const cwd = "/tmp/opencode-acp-test"
- const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
- const data = Buffer.from("replay-image").toString("base64")
-
- sdk.session.messages = async () => ({
- data: [
- {
- info: {
- role: "assistant",
- sessionID: sessionId,
- },
- parts: [
- {
- id: "part_replay",
- sessionID: sessionId,
- messageID: "msg_replay",
- type: "tool",
- callID: "call_replay_image",
- tool: "webfetch",
- state: {
- status: "completed",
- input: { url: "https://example.com/image.png" },
- output: "Image fetched successfully",
- title: "webfetch",
- metadata: {},
- time: { start: Date.now() - 1, end: Date.now() },
- attachments: [
- {
- id: "part_replay_image",
- sessionID: sessionId,
- messageID: "msg_replay",
- type: "file",
- mime: "image/jpeg",
- filename: "image.jpg",
- url: `data:image/jpeg;base64,${data}`,
- },
- ],
- },
- },
- ],
- },
- ],
- })
-
- await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
-
- const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image")
- expect(update?.content).toContainEqual({
- type: "content",
- content: { type: "text", text: "Image fetched successfully" },
- })
- expect(update?.content).toContainEqual({
- type: "content",
- content: { type: "image", mimeType: "image/jpeg", data },
- })
-
- stop()
- },
- })
- })
-
- test("does not emit duplicate synthetic pending after replayed running tool", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent()
- const cwd = "/tmp/opencode-acp-test"
- const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
- const input = { command: "echo hi", description: "run command" }
-
- sdk.session.messages = async () => ({
- data: [
- {
- info: {
- role: "assistant",
- sessionID: sessionId,
- },
- parts: [
- {
- type: "tool",
- callID: "call_1",
- tool: "bash",
- state: {
- status: "running",
- input,
- metadata: { output: "hi\n" },
- time: { start: Date.now() },
- },
- },
- ],
- },
- ],
- })
-
- await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
- controller.push(
- toolEvent(sessionId, cwd, {
- callID: "call_1",
- tool: "bash",
- status: "running",
- input,
- metadata: { output: "hi\nthere\n" },
- }),
- )
- await pollUntil(
- () =>
- sessionUpdates
- .filter((u) => u.sessionId === sessionId)
- .map((u) => u.update)
- .filter((u) => "toolCallId" in u && u.toolCallId === "call_1")
- .map((u) => u.sessionUpdate)
- .filter((u) => u === "tool_call" || u === "tool_call_update").length >= 3,
- "expected 3 tool events for call_1",
- )
-
- const types = sessionUpdates
- .filter((u) => u.sessionId === sessionId)
- .map((u) => u.update)
- .filter((u) => "toolCallId" in u && u.toolCallId === "call_1")
- .map((u) => u.sessionUpdate)
- .filter((u) => u === "tool_call" || u === "tool_call_update")
-
- expect(types).toEqual(["tool_call", "tool_call_update", "tool_call_update"])
- stop()
- },
- })
- })
-
- test("clears bash snapshot marker on pending state", async () => {
- await using tmp = await tmpdir()
- await provideTestInstance({
- directory: tmp.path,
- fn: async () => {
- const { agent, controller, sessionUpdates, stop } = createFakeAgent()
- const cwd = "/tmp/opencode-acp-test"
- const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
- const input = { command: "echo hello", description: "run command" }
-
- controller.push(
- toolEvent(sessionId, cwd, {
- callID: "call_1",
- tool: "bash",
- status: "running",
- input,
- metadata: { output: "a" },
- }),
- )
- controller.push(
- toolEvent(sessionId, cwd, {
- callID: "call_1",
- tool: "bash",
- status: "pending",
- input,
- raw: '{"command":"echo hello"}',
- }),
- )
- controller.push(
- toolEvent(sessionId, cwd, {
- callID: "call_1",
- tool: "bash",
- status: "running",
- input,
- metadata: { output: "a" },
- }),
- )
- await pollUntil(
- () =>
- sessionUpdates
- .filter((u) => u.sessionId === sessionId)
- .filter((u) => isToolCallUpdate(u.update))
- .map((u) => inProgressText(u.update))
- .filter((t) => t === "a").length >= 2,
- "expected two 'a' bash snapshots after pending reset",
- )
-
- const snapshots = sessionUpdates
- .filter((u) => u.sessionId === sessionId)
- .filter((u) => isToolCallUpdate(u.update))
- .map((u) => inProgressText(u.update))
-
- expect(snapshots).toEqual(["a", "a"])
- stop()
- },
- })
- })
-})
diff --git a/packages/opencode/test/acp-next/event.test.ts b/packages/opencode/test/acp/event.test.ts
similarity index 96%
rename from packages/opencode/test/acp-next/event.test.ts
rename to packages/opencode/test/acp/event.test.ts
index 92b65b8881a2..c79baf035e0a 100644
--- a/packages/opencode/test/acp-next/event.test.ts
+++ b/packages/opencode/test/acp/event.test.ts
@@ -2,10 +2,10 @@ import { describe, expect, it } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
-import { ACPNextEvent } from "@/acp-next/event"
-import * as ACPNextService from "@/acp-next/service"
-import { Directory } from "@/acp-next/directory"
-import { ACPNextSession } from "@/acp-next/session"
+import { ACPEvent } from "@/acp/event"
+import * as ACPService from "@/acp/service"
+import { Directory } from "@/acp/directory"
+import { ACPSession } from "@/acp/session"
type SessionUpdateParams = Parameters[0]
type ToolSessionUpdateParams = SessionUpdateParams & {
@@ -30,8 +30,8 @@ const pollUntil = async (
}
function makeSessionService() {
- return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
- ACPNextSession.Service.use((service) => Effect.succeed(service)),
+ return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
+ ACPSession.Service.use((service) => Effect.succeed(service)),
)
}
@@ -107,7 +107,7 @@ function createHarness(messages: Record = {}) {
},
} satisfies Pick
const session = makeSessionService()
- const subscription = new ACPNextEvent.Subscription({ sdk, connection, session })
+ const subscription = new ACPEvent.Subscription({ sdk, connection, session })
return { calls, connection, events, sdk, session, subscription, updates }
}
@@ -296,7 +296,7 @@ function toolUpdates(updates: SessionUpdateParams[]) {
}
async function createKnownSession(
- session: ACPNextSession.Interface,
+ session: ACPSession.Interface,
sessionId: string,
part: { messageId: string; partId: string; partType: Part["type"]; role?: Message["role"] },
) {
@@ -312,7 +312,7 @@ async function createKnownSession(
)
}
-describe("acp-next event routing", () => {
+describe("acp event routing", () => {
it("routes message.part.delta by sessionID without cross-session pollution", async () => {
const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
@@ -348,8 +348,8 @@ describe("acp-next event routing", () => {
it("does not create extra subscriptions on repeated loadSession", async () => {
const harness = createHarness()
- let subscription: ACPNextEvent.Subscription | undefined
- const service = ACPNextService.make({
+ let subscription: ACPEvent.Subscription | undefined
+ const service = ACPService.make({
sdk: harness.sdk,
connection: harness.connection,
directory: {
@@ -439,8 +439,8 @@ describe("acp-next event routing", () => {
return Promise.resolve()
},
} satisfies Pick
- let subscription: ACPNextEvent.Subscription | undefined
- const service = ACPNextService.make({
+ let subscription: ACPEvent.Subscription | undefined
+ const service = ACPService.make({
sdk: {
global: {
event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }),
diff --git a/packages/opencode/test/acp-next/permission.test.ts b/packages/opencode/test/acp/permission.test.ts
similarity index 94%
rename from packages/opencode/test/acp-next/permission.test.ts
rename to packages/opencode/test/acp/permission.test.ts
index ec9712d72d80..fb86026af6c4 100644
--- a/packages/opencode/test/acp-next/permission.test.ts
+++ b/packages/opencode/test/acp/permission.test.ts
@@ -7,8 +7,8 @@ import type {
} from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
-import { ACPNextEvent } from "@/acp-next/event"
-import { ACPNextSession } from "@/acp-next/session"
+import { ACPEvent } from "@/acp/event"
+import { ACPSession } from "@/acp/session"
type PermissionEvent = Extract
type PermissionReplyParams = Parameters[0]
@@ -28,8 +28,8 @@ const pollUntil = async (
}
function makeSessionService() {
- return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
- ACPNextSession.Service.use((service) => Effect.succeed(service)),
+ return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
+ ACPSession.Service.use((service) => Effect.succeed(service)),
)
}
@@ -62,17 +62,17 @@ function createHarness(
return Promise.resolve()
},
} satisfies Pick
- const subscription = new ACPNextEvent.Subscription({ sdk, connection, session })
+ const subscription = new ACPEvent.Subscription({ sdk, connection, session })
return { connection, replies, requests, sdk, session, subscription, updates }
}
-async function createSession(session: ACPNextSession.Interface, sessionId: string, cwd = "/workspace") {
+async function createSession(session: ACPSession.Interface, sessionId: string, cwd = "/workspace") {
await Effect.runPromise(session.create({ id: sessionId, cwd }))
}
async function createKnownTextPart(
- session: ACPNextSession.Interface,
+ session: ACPSession.Interface,
sessionId: string,
messageId: string,
partId: string,
@@ -137,7 +137,7 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
.join("")
}
-describe("acp-next permissions", () => {
+describe("acp permissions", () => {
it("sends requestPermission and replies with the selected outcome", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts
similarity index 96%
rename from packages/opencode/test/acp-next/service-session.test.ts
rename to packages/opencode/test/acp/service-session.test.ts
index a35e6aa0ae80..7a09fd1cc910 100644
--- a/packages/opencode/test/acp-next/service-session.test.ts
+++ b/packages/opencode/test/acp/service-session.test.ts
@@ -12,10 +12,10 @@ import type {
} from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
-import * as ACPNextService from "@/acp-next/service"
-import * as ACPNextError from "@/acp-next/error"
-import { ACPNextSession } from "@/acp-next/session"
-import { UsageService } from "@/acp-next/usage"
+import * as ACPService from "@/acp/service"
+import * as ACPError from "@/acp/error"
+import { ACPSession } from "@/acp/session"
+import { UsageService } from "@/acp/usage"
import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider"
@@ -141,7 +141,7 @@ const provider: Provider.Info = {
},
}
-describe("ACP next service sessions", () => {
+describe("ACP service sessions", () => {
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
const updates: SessionNotification[] = []
const mcpAdds: string[] = []
@@ -254,7 +254,7 @@ describe("ACP next service sessions", () => {
})
return {
- service: ACPNextService.make({ sdk, connection, usage }),
+ service: ACPService.make({ sdk, connection, usage }),
updates,
mcpAdds,
aborts,
@@ -374,7 +374,7 @@ describe("ACP next service sessions", () => {
const missing = await Effect.runPromise(
service
.setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" })
- .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
+ .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
)
expect(missing.code).toBe(-32602)
expect(aborts).toEqual([created.sessionId])
@@ -382,8 +382,8 @@ describe("ACP next service sessions", () => {
})
it("does not fail close when backing abort fails", async () => {
- const sessionService = ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
- ACPNextSession.Service.use((service) => Effect.succeed(service)),
+ const sessionService = ManagedRuntime.make(ACPSession.defaultLayer).runSync(
+ ACPSession.Service.use((service) => Effect.succeed(service)),
)
const { service } = makeService()
const sdk = {
@@ -405,7 +405,7 @@ describe("ACP next service sessions", () => {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
- const closing = ACPNextService.make({ sdk, session: sessionService })
+ const closing = ACPService.make({ sdk, session: sessionService })
await Effect.runPromise(sessionService.create({ id: "ses_close", cwd: "/workspace" }))
expect(await Effect.runPromise(closing.closeSession({ sessionId: "ses_close" }))).toEqual({})
@@ -467,7 +467,7 @@ describe("ACP next service sessions", () => {
})
it("maps provider auth failures to auth-required request errors", async () => {
- const service = ACPNextService.make({
+ const service = ACPService.make({
sdk: {
config: {
providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
@@ -485,7 +485,7 @@ describe("ACP next service sessions", () => {
const error = await Effect.runPromise(
service
.newSession({ cwd: "/workspace", mcpServers: [] })
- .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
+ .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
)
expect(error.code).toBe(-32000)
@@ -519,12 +519,12 @@ describe("ACP next service sessions", () => {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
- const service = ACPNextService.make({ sdk })
+ const service = ACPService.make({ sdk })
const first = await Effect.runPromise(
service
.newSession({ cwd: "/workspace", mcpServers: [] })
- .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
+ .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
)
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@@ -562,7 +562,7 @@ describe("ACP next service sessions", () => {
},
},
} as unknown as OpencodeClient
- const service = ACPNextService.make({ sdk })
+ const service = ACPService.make({ sdk })
await Effect.runPromise(
service.newSession({
@@ -603,7 +603,7 @@ describe("ACP next service sessions", () => {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
- const service = ACPNextService.make({ sdk })
+ const service = ACPService.make({ sdk })
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@@ -642,7 +642,7 @@ describe("ACP next service sessions", () => {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
- const service = ACPNextService.make({ sdk })
+ const service = ACPService.make({ sdk })
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@@ -709,7 +709,7 @@ describe("ACP next service sessions", () => {
Effect.runPromise(
service
.setSessionConfigOption({ sessionId: session.sessionId, ...input })
- .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
+ .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
),
),
)
@@ -759,7 +759,7 @@ describe("ACP next service sessions", () => {
},
},
} as unknown as OpencodeClient
- const service = ACPNextService.make({ sdk })
+ const service = ACPService.make({ sdk })
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
@@ -814,7 +814,7 @@ describe("ACP next service sessions", () => {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
- const service = ACPNextService.make({ sdk })
+ const service = ACPService.make({ sdk })
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const updated = await Effect.runPromise(
service.setSessionConfigOption({
@@ -884,7 +884,7 @@ describe("ACP next service sessions", () => {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
- const service = ACPNextService.make({ sdk })
+ const service = ACPService.make({ sdk })
const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@@ -1065,7 +1065,7 @@ describe("ACP next service sessions", () => {
it("maps prompt auth failures to auth-required request errors", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- const failing = ACPNextService.make({
+ const failing = ACPService.make({
sdk: {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
@@ -1099,7 +1099,7 @@ describe("ACP next service sessions", () => {
const error = await Effect.runPromise(
failing
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
- .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
+ .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
)
expect(error.code).toBe(-32000)
diff --git a/packages/opencode/test/acp-next/session.test.ts b/packages/opencode/test/acp/session.test.ts
similarity index 64%
rename from packages/opencode/test/acp-next/session.test.ts
rename to packages/opencode/test/acp/session.test.ts
index 0c1cb16cc784..c03388037547 100644
--- a/packages/opencode/test/acp-next/session.test.ts
+++ b/packages/opencode/test/acp/session.test.ts
@@ -1,14 +1,14 @@
import { describe, expect } from "bun:test"
import type { McpServer } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
-import * as ACPNextError from "@/acp-next/error"
-import * as ACPNextSession from "@/acp-next/session"
+import * as ACPError from "@/acp/error"
+import * as ACPSession from "@/acp/session"
import { ModelID, ProviderID } from "@/provider/schema"
import { testEffect } from "../lib/effect"
-const sessionTest = testEffect(ACPNextSession.defaultLayer)
+const sessionTest = testEffect(ACPSession.defaultLayer)
-const model = (providerID: string, modelID: string): ACPNextSession.SelectedModel => ({
+const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({
providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID),
})
@@ -20,11 +20,11 @@ const mcpServer: McpServer = {
env: [],
}
-describe("acp-next session state", () => {
+describe("acp session state", () => {
sessionTest.effect("creates and retrieves session state", () =>
Effect.gen(function* () {
const createdAt = new Date("2026-05-25T00:00:00.000Z")
- const created = yield* ACPNextSession.Service.use((session) =>
+ const created = yield* ACPSession.Service.use((session) =>
session.create({
id: "ses_1",
cwd: "/workspace",
@@ -35,7 +35,7 @@ describe("acp-next session state", () => {
modeId: "build",
}),
)
- const loaded = yield* ACPNextSession.Service.use((session) => session.get("ses_1"))
+ const loaded = yield* ACPSession.Service.use((session) => session.get("ses_1"))
expect(created).toMatchObject({
id: "ses_1",
@@ -52,17 +52,17 @@ describe("acp-next session state", () => {
sessionTest.effect("fails required lookups with typed SessionNotFound", () =>
Effect.gen(function* () {
- const error = yield* ACPNextSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip)
+ const error = yield* ACPSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip)
- expect(error).toBeInstanceOf(ACPNextError.SessionNotFoundError)
+ expect(error).toBeInstanceOf(ACPError.SessionNotFoundError)
expect(error.sessionId).toBe("ses_missing")
}),
)
sessionTest.effect("tryGet lets event routing ignore unknown sessions", () =>
Effect.gen(function* () {
- const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_missing"))
- const missingPart = yield* ACPNextSession.Service.use((session) =>
+ const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_missing"))
+ const missingPart = yield* ACPSession.Service.use((session) =>
session.tryGetPartMetadata({ sessionId: "ses_missing", messageId: "msg_1", partId: "part_1" }),
)
@@ -73,7 +73,7 @@ describe("acp-next session state", () => {
sessionTest.effect("updates selected model while preserving session identity and inputs", () =>
Effect.gen(function* () {
- yield* ACPNextSession.Service.use((session) =>
+ yield* ACPSession.Service.use((session) =>
session.create({
id: "ses_model",
cwd: "/workspace",
@@ -84,7 +84,7 @@ describe("acp-next session state", () => {
}),
)
- const updated = yield* ACPNextSession.Service.use((session) =>
+ const updated = yield* ACPSession.Service.use((session) =>
session.setModel("ses_model", model("openai", "gpt-5")),
)
@@ -99,7 +99,7 @@ describe("acp-next session state", () => {
sessionTest.effect("updates selected variant and mode independently", () =>
Effect.gen(function* () {
- yield* ACPNextSession.Service.use((session) =>
+ yield* ACPSession.Service.use((session) =>
session.load({
id: "ses_config",
cwd: "/workspace",
@@ -109,21 +109,21 @@ describe("acp-next session state", () => {
}),
)
- yield* ACPNextSession.Service.use((session) => session.setVariant("ses_config", "high"))
- expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
- expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan")
+ yield* ACPSession.Service.use((session) => session.setVariant("ses_config", "high"))
+ expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
+ expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan")
- yield* ACPNextSession.Service.use((session) => session.setMode("ses_config", "build"))
- expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
- expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("build")
+ yield* ACPSession.Service.use((session) => session.setMode("ses_config", "build"))
+ expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
+ expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("build")
}),
)
sessionTest.effect("records known message part metadata for delta routing", () =>
Effect.gen(function* () {
- yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" }))
+ yield* ACPSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" }))
- const metadata = yield* ACPNextSession.Service.use((session) =>
+ const metadata = yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_parts",
messageId: "msg_1",
@@ -132,7 +132,7 @@ describe("acp-next session state", () => {
metadata: { output: "first chunk" },
}),
)
- const routed = yield* ACPNextSession.Service.use((session) =>
+ const routed = yield* ACPSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", partId: "part_1" }),
)
@@ -148,8 +148,8 @@ describe("acp-next session state", () => {
sessionTest.effect("keeps repeated part ids distinct across messages", () =>
Effect.gen(function* () {
- yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" }))
- yield* ACPNextSession.Service.use((session) =>
+ yield* ACPSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" }))
+ yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_duplicate_parts",
messageId: "msg_1",
@@ -157,7 +157,7 @@ describe("acp-next session state", () => {
metadata: { output: "from first message" },
}),
)
- yield* ACPNextSession.Service.use((session) =>
+ yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_duplicate_parts",
messageId: "msg_2",
@@ -166,10 +166,10 @@ describe("acp-next session state", () => {
}),
)
- const first = yield* ACPNextSession.Service.use((session) =>
+ const first = yield* ACPSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", partId: "part_1" }),
)
- const second = yield* ACPNextSession.Service.use((session) =>
+ const second = yield* ACPSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", partId: "part_1" }),
)
@@ -180,14 +180,14 @@ describe("acp-next session state", () => {
sessionTest.effect("removing a session clears its known part metadata", () =>
Effect.gen(function* () {
- yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" }))
- yield* ACPNextSession.Service.use((session) =>
+ yield* ACPSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" }))
+ yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
)
- const removed = yield* ACPNextSession.Service.use((session) => session.remove("ses_remove"))
- const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_remove"))
- const missingPart = yield* ACPNextSession.Service.use((session) =>
+ const removed = yield* ACPSession.Service.use((session) => session.remove("ses_remove"))
+ const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_remove"))
+ const missingPart = yield* ACPSession.Service.use((session) =>
session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
)
diff --git a/packages/opencode/test/acp-next/tool.test.ts b/packages/opencode/test/acp/tool.test.ts
similarity index 98%
rename from packages/opencode/test/acp-next/tool.test.ts
rename to packages/opencode/test/acp/tool.test.ts
index 0e0cc1e3ffc6..7587f7bbd91b 100644
--- a/packages/opencode/test/acp-next/tool.test.ts
+++ b/packages/opencode/test/acp/tool.test.ts
@@ -7,9 +7,9 @@ import {
shellOutputSnapshot,
toLocations,
toToolKind,
-} from "../../src/acp-next/tool"
+} from "../../src/acp/tool"
-describe("acp-next tool conversion", () => {
+describe("acp tool conversion", () => {
test("maps OpenCode tool ids to ACP tool kinds", () => {
expect(toToolKind("bash")).toBe("execute")
expect(toToolKind("shell")).toBe("execute")
diff --git a/packages/opencode/test/acp-next/usage.test.ts b/packages/opencode/test/acp/usage.test.ts
similarity index 99%
rename from packages/opencode/test/acp-next/usage.test.ts
rename to packages/opencode/test/acp/usage.test.ts
index 77c17d4f72ea..343a27013804 100644
--- a/packages/opencode/test/acp-next/usage.test.ts
+++ b/packages/opencode/test/acp/usage.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
-import { UsageService } from "@/acp-next/usage"
+import { UsageService } from "@/acp/usage"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
@@ -122,7 +122,7 @@ const connection = (updates: SessionNotification[]) => ({
},
})
-describe("acp-next usage", () => {
+describe("acp usage", () => {
test("builds ACP Usage from assistant token shape", () => {
expect(
UsageService.buildUsage({
diff --git a/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts b/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts
deleted file mode 100644
index a805bfef29f1..000000000000
--- a/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts
+++ /dev/null
@@ -1,298 +0,0 @@
-import { describe, expect } from "bun:test"
-import type {
- CloseSessionResponse,
- InitializeResponse,
- NewSessionResponse,
- ResumeSessionResponse,
- SessionNotification,
- SetSessionConfigOptionResponse,
-} from "@agentclientprotocol/sdk"
-import { Effect } from "effect"
-import { mkdir } from "node:fs/promises"
-import path from "node:path"
-import { cliIt } from "../../lib/cli-process"
-import { testProviderConfig } from "../../lib/test-provider"
-import {
- createAcpClient,
- expectOk,
- firstAlternateValue,
- flattenSelectOptions,
- selectConfigOption,
-} from "./acp-test-client"
-
-describe("opencode acp verifier compatibility baseline", () => {
- cliIt.live(
- "initialize advertises close and resume capabilities",
- ({ opencode }) =>
- Effect.gen(function* () {
- const acp = createAcpClient(yield* opencode.acp())
- const initialized = expectOk(
- yield* acp.request("initialize", {
- protocolVersion: 1,
- }),
- )
-
- expect(initialized.protocolVersion).toBe(1)
- expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
- expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
- }),
- 60_000,
- )
-
- cliIt.live(
- "first session returns model options",
- ({ home, llm, opencode }) =>
- Effect.gen(function* () {
- const acp = createAcpClient(
- yield* opencode.acp({
- env: {
- OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
- },
- }),
- )
- yield* acp.request("initialize", {
- protocolVersion: 1,
- clientCapabilities: {},
- clientInfo: { name: "opencode-local-acp-baseline", version: "0.1.0" },
- })
- const session = expectOk(
- yield* acp.request("session/new", {
- cwd: home,
- mcpServers: [],
- }),
- )
- const model = selectConfigOption(session.configOptions, "model")
- expect(model?.category).toBe("model")
- expect(model?.currentValue).toBe("test/test-model")
- expect(model ? flattenSelectOptions(model).length : 0).toBeGreaterThanOrEqual(2)
- }),
- 60_000,
- )
-
- cliIt.live(
- "newSession can be called repeatedly",
- ({ home, llm, opencode }) =>
- Effect.gen(function* () {
- const acp = createAcpClient(
- yield* opencode.acp({
- env: {
- OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
- },
- }),
- )
- yield* acp.request("initialize", { protocolVersion: 1 })
- yield* acp.request("session/new", { cwd: home, mcpServers: [] })
-
- const session = expectOk(
- yield* acp.request("session/new", {
- cwd: home,
- mcpServers: [],
- }),
- )
- expect(session.sessionId).toBeTruthy()
- }),
- 60_000,
- )
-
- cliIt.live(
- "model switch updates currentValue",
- ({ home, llm, opencode }) =>
- Effect.gen(function* () {
- const acp = createAcpClient(
- yield* opencode.acp({
- env: {
- OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
- },
- }),
- )
- yield* acp.request("initialize", { protocolVersion: 1 })
- const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] }))
- const model = selectConfigOption(session.configOptions, "model")
- expect(model).toBeDefined()
- const nextModel = model
- ? flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
- : undefined
- expect(nextModel).toBe("test/second-model")
-
- const updated = expectOk(
- yield* acp.request("session/set_config_option", {
- sessionId: session.sessionId,
- configId: "model",
- value: nextModel,
- }),
- )
-
- expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
- }),
- 60_000,
- )
-
- cliIt.live(
- "effort option is listed for variant-capable models and can switch",
- ({ home, llm, opencode }) =>
- Effect.gen(function* () {
- const acp = createAcpClient(
- yield* opencode.acp({
- env: {
- OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
- },
- }),
- )
- yield* acp.request("initialize", { protocolVersion: 1 })
- const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] }))
- const effort = selectConfigOption(session.configOptions, "effort")
- expect(effort?.category).toBe("thought_level")
- const nextEffort = effort ? firstAlternateValue(effort) : undefined
- expect(nextEffort).toBe("high")
-
- const updated = expectOk(
- yield* acp.request("session/set_config_option", {
- sessionId: session.sessionId,
- configId: "effort",
- value: nextEffort,
- }),
- )
-
- expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
- }),
- 60_000,
- )
-
- cliIt.live(
- "default test provider documents missing effort option when the model has no variants",
- ({ home, llm, opencode }) =>
- Effect.gen(function* () {
- const acp = createAcpClient(
- yield* opencode.acp({
- env: {
- OPENCODE_CONFIG_CONTENT: JSON.stringify(noVariantConfig(llm.url)),
- },
- }),
- )
- yield* acp.request("initialize", { protocolVersion: 1 })
- const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] }))
-
- expect(selectConfigOption(session.configOptions, "model")?.currentValue).toBe("test/test-model")
- expect(selectConfigOption(session.configOptions, "effort")).toBeUndefined()
- }),
- 60_000,
- )
-
- cliIt.live(
- "skill slash command appears through available_commands_update",
- ({ home, llm, opencode }) =>
- Effect.gen(function* () {
- const skills = path.join(home, "skills")
- yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true }))
- yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill))
- const acp = createAcpClient(
- yield* opencode.acp({
- env: {
- OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)),
- },
- }),
- )
- yield* acp.request("initialize", { protocolVersion: 1 })
- const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] }))
-
- const update = yield* acp.waitForNotification(
- "session/update",
- (params) =>
- params.sessionId === session.sessionId &&
- params.update.sessionUpdate === "available_commands_update" &&
- params.update.availableCommands.some((command) => command.name === "verifier-skill"),
- )
-
- expect(update.params?.sessionId).toBe(session.sessionId)
- }),
- 60_000,
- )
-
- cliIt.live(
- "close request succeeds for a live session",
- ({ home, opencode }) =>
- Effect.gen(function* () {
- const acp = createAcpClient(yield* opencode.acp())
- yield* acp.request("initialize", { protocolVersion: 1 })
- const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] }))
-
- expectOk(yield* acp.request("session/close", { sessionId: session.sessionId }))
- }),
- 60_000,
- )
-
- cliIt.live(
- "resume request succeeds for a created session",
- ({ home, opencode }) =>
- Effect.gen(function* () {
- const acp = createAcpClient(yield* opencode.acp())
- yield* acp.request("initialize", { protocolVersion: 1 })
- const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] }))
-
- const resumed = expectOk(
- yield* acp.request("session/resume", {
- sessionId: session.sessionId,
- cwd: home,
- mcpServers: [],
- }),
- )
- expect(resumed.configOptions?.length).toBeGreaterThan(0)
- }),
- 60_000,
- )
-})
-
-function verifierConfig(llmUrl: string, skills?: string) {
- const config = testProviderConfig(llmUrl)
- return {
- ...config,
- model: "test/test-model",
- ...(skills ? { skills: { paths: [skills] } } : {}),
- provider: {
- test: {
- ...config.provider.test,
- models: {
- "test-model": {
- ...config.provider.test.models["test-model"],
- variants: {
- low: {},
- high: {},
- },
- },
- "second-model": {
- ...config.provider.test.models["test-model"],
- id: "second-model",
- name: "Second Test Model",
- },
- },
- },
- },
- }
-}
-
-function noVariantConfig(llmUrl: string) {
- const config = verifierConfig(llmUrl)
- return {
- ...config,
- provider: {
- test: {
- ...config.provider.test,
- models: {
- "test-model": {
- ...config.provider.test.models["test-model"],
- variants: undefined,
- },
- "second-model": config.provider.test.models["second-model"],
- },
- },
- },
- }
-}
-
-const verifierSkill = `---
-name: verifier-skill
-description: Verifier compatibility skill.
----
-
-# Verifier Skill
-`
diff --git a/packages/opencode/test/cli/acp/acp-process.test.ts b/packages/opencode/test/cli/acp/acp-process.test.ts
deleted file mode 100644
index a3c244c6c8ce..000000000000
--- a/packages/opencode/test/cli/acp/acp-process.test.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-// Subprocess integration tests for `opencode acp`. ACP is a JSON-RPC
-// protocol spoken over stdin/stdout (not HTTP) — see src/acp/README.md.
-// This is the only test tier that exercises the full pipe of bun startup →
-// server boot → ACP agent init → stdio framing → graceful shutdown.
-import { describe, expect } from "bun:test"
-import { Duration, Effect } from "effect"
-import { cliIt } from "../../lib/cli-process"
-
-describe("opencode acp (subprocess)", () => {
- // Smoke test: send the `initialize` request from src/acp/README.md and
- // assert the response advertises the same protocol version and a non-empty
- // capabilities block. If this fails, every other ACP test will too — start
- // debugging here.
- cliIt.live(
- "responds to initialize with protocolVersion 1 and capabilities",
- ({ opencode }) =>
- Effect.gen(function* () {
- const acp = yield* opencode.acp()
-
- yield* acp.send({
- jsonrpc: "2.0",
- id: 1,
- method: "initialize",
- params: { protocolVersion: 1 },
- })
-
- // Tight deadline — the response should arrive within a few seconds
- // once startup completes. A hang means the agent never finished init,
- // which is a real regression and not a tuning issue.
- const response = (yield* acp.receive.pipe(Effect.timeout(Duration.seconds(10)))) as {
- jsonrpc: string
- id: number
- result?: { protocolVersion: number; agentCapabilities: Record }
- error?: unknown
- }
-
- expect(response.jsonrpc).toBe("2.0")
- expect(response.id).toBe(1)
- expect(response.error).toBeUndefined()
- expect(response.result?.protocolVersion).toBe(1)
- expect(response.result?.agentCapabilities).toBeDefined()
- }),
- 60_000,
- )
-
- // Lock in the scope-close kill path. ACP's clean shutdown is "EOF on stdin"
- // — if a future refactor breaks the stdin-end branch in the handler, the
- // process would only exit on SIGTERM fallback (2s in the harness). This
- // test passing within the inner-scope assertion proves the EOF path works.
- cliIt.live(
- "exits cleanly when stdin is closed (scope close)",
- ({ opencode }) =>
- Effect.gen(function* () {
- const exitedPromise = yield* Effect.scoped(
- Effect.gen(function* () {
- const acp = yield* opencode.acp()
- // Capture the Promise — scope-close fires the finalizer which
- // ends stdin, and ACP should exit gracefully.
- return acp.exited
- }),
- )
-
- const code = yield* Effect.promise(() => exitedPromise)
- // Bun returns a number for normal exit. Anything goes for SIGTERM,
- // but we still require resolution within the test timeout.
- expect(typeof code === "number" || code === null).toBe(true)
- }),
- 60_000,
- )
-})
diff --git a/packages/opencode/test/cli/acp-next/config-options.test.ts b/packages/opencode/test/cli/acp/config-options.test.ts
similarity index 91%
rename from packages/opencode/test/cli/acp-next/config-options.test.ts
rename to packages/opencode/test/cli/acp/config-options.test.ts
index 1fb69d5ef88d..0c712f0f2331 100644
--- a/packages/opencode/test/cli/acp-next/config-options.test.ts
+++ b/packages/opencode/test/cli/acp/config-options.test.ts
@@ -2,9 +2,9 @@ import { describe, expect } from "bun:test"
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
-import { expectOk, flattenSelectOptions, selectConfigOption } from "../acp/acp-test-client"
+import { expectOk, flattenSelectOptions, selectConfigOption } from "./acp-test-client"
import {
- createAcpNextClient,
+ createAcpClient,
expectAlternateValue,
expectSelectOption,
initialize,
@@ -12,12 +12,12 @@ import {
verifierConfig,
} from "./helpers"
-describe("opencode acp-next config option subprocess", () => {
+describe("opencode acp config option subprocess", () => {
cliIt.live(
'model option is listed with category "model"',
({ home, llm, opencode }) =>
Effect.gen(function* () {
- const acp = yield* createAcpNextClient(
+ const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@@ -35,7 +35,7 @@ describe("opencode acp-next config option subprocess", () => {
"model switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
- const acp = yield* createAcpNextClient(
+ const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@@ -62,7 +62,7 @@ describe("opencode acp-next config option subprocess", () => {
'effort option is listed with category "thought_level" when selected model supports variants',
({ home, llm, opencode }) =>
Effect.gen(function* () {
- const acp = yield* createAcpNextClient(
+ const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@@ -80,7 +80,7 @@ describe("opencode acp-next config option subprocess", () => {
"effort switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
- const acp = yield* createAcpNextClient(
+ const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
diff --git a/packages/opencode/test/cli/acp-next/helpers.ts b/packages/opencode/test/cli/acp/helpers.ts
similarity index 86%
rename from packages/opencode/test/cli/acp-next/helpers.ts
rename to packages/opencode/test/cli/acp/helpers.ts
index 6e92a73ea34f..92c560d941ca 100644
--- a/packages/opencode/test/cli/acp-next/helpers.ts
+++ b/packages/opencode/test/cli/acp/helpers.ts
@@ -4,23 +4,16 @@ import { Effect } from "effect"
import type { CliFixture } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import {
- createAcpClient,
+ createAcpClient as createJsonRpcAcpClient,
expectOk,
flattenSelectOptions,
selectConfigOption,
type AcpClient,
-} from "../acp/acp-test-client"
+} from "./acp-test-client"
-export function createAcpNextClient(input: Pick, env?: Record) {
+export function createAcpClient(input: Pick, env?: Record) {
return Effect.gen(function* () {
- return createAcpClient(
- yield* input.opencode.acp({
- env: {
- OPENCODE_ACP_NEXT: "1",
- ...env,
- },
- }),
- )
+ return createJsonRpcAcpClient(yield* input.opencode.acp(env ? { env } : undefined))
})
}
@@ -30,7 +23,7 @@ export function initialize(acp: AcpClient) {
yield* acp.request("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
- clientInfo: { name: "opencode-local-acp-next", version: "0.1.0" },
+ clientInfo: { name: "opencode-local-acp", version: "0.1.0" },
}),
)
})
diff --git a/packages/opencode/test/cli/acp-next/initialize-auth.test.ts b/packages/opencode/test/cli/acp/initialize-auth.test.ts
similarity index 87%
rename from packages/opencode/test/cli/acp-next/initialize-auth.test.ts
rename to packages/opencode/test/cli/acp/initialize-auth.test.ts
index 9db7c75c8ac6..709c27f3a319 100644
--- a/packages/opencode/test/cli/acp-next/initialize-auth.test.ts
+++ b/packages/opencode/test/cli/acp/initialize-auth.test.ts
@@ -2,14 +2,14 @@ import { describe, expect } from "bun:test"
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
-import { createAcpNextClient, expectErrorCode, initialize } from "./helpers"
+import { createAcpClient, expectErrorCode, initialize } from "./helpers"
-describe("opencode acp-next initialize/auth subprocess", () => {
+describe("opencode acp initialize/auth subprocess", () => {
cliIt.live(
"initialize responds with capabilities",
({ opencode }) =>
Effect.gen(function* () {
- const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
+ const initialized = yield* initialize(yield* createAcpClient({ opencode }))
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
@@ -30,7 +30,7 @@ describe("opencode acp-next initialize/auth subprocess", () => {
"auth negotiation is explicit and safe",
({ opencode }) =>
Effect.gen(function* () {
- const acp = yield* createAcpNextClient({ opencode })
+ const acp = yield* createAcpClient({ opencode })
const initialized = yield* initialize(acp)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
@@ -50,7 +50,7 @@ describe("opencode acp-next initialize/auth subprocess", () => {
"initialize without terminal-auth metadata keeps auth command implicit",
({ opencode }) =>
Effect.gen(function* () {
- const acp = yield* createAcpNextClient({ opencode })
+ const acp = yield* createAcpClient({ opencode })
const initialized = yield* acp.request("initialize", { protocolVersion: 1 })
expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login")
diff --git a/packages/opencode/test/cli/acp-next/lifecycle.test.ts b/packages/opencode/test/cli/acp/lifecycle.test.ts
similarity index 85%
rename from packages/opencode/test/cli/acp-next/lifecycle.test.ts
rename to packages/opencode/test/cli/acp/lifecycle.test.ts
index a1b54037af40..9f2558ea2f58 100644
--- a/packages/opencode/test/cli/acp-next/lifecycle.test.ts
+++ b/packages/opencode/test/cli/acp/lifecycle.test.ts
@@ -7,15 +7,15 @@ import type {
} from "@agentclientprotocol/sdk"
import { Duration, Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
-import { expectOk, selectConfigOption } from "../acp/acp-test-client"
-import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
+import { expectOk, selectConfigOption } from "./acp-test-client"
+import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers"
-describe("opencode acp-next lifecycle subprocess", () => {
+describe("opencode acp lifecycle subprocess", () => {
cliIt.live(
"stdin EOF exits cleanly",
({ opencode }) =>
Effect.gen(function* () {
- const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
+ const acp = yield* opencode.acp()
acp.close()
const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5)))
@@ -28,7 +28,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"close capability and close request",
({ home, llm, opencode }) =>
Effect.gen(function* () {
- const acp = yield* createAcpNextClient(
+ const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@@ -45,7 +45,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"loadSession capability and load request return session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
- const acp = yield* createAcpNextClient(
+ const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@@ -69,7 +69,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"list request includes a live ACP-created session",
({ home, llm, opencode }) =>
Effect.gen(function* () {
- const acp = yield* createAcpNextClient(
+ const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@@ -86,7 +86,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"resume capability advertisement",
({ opencode }) =>
Effect.gen(function* () {
- const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
+ const initialized = yield* initialize(yield* createAcpClient({ opencode }))
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
@@ -97,7 +97,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"resume request returns session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
- const acp = yield* createAcpNextClient(
+ const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
diff --git a/packages/opencode/test/cli/acp-next/prompt-content.test.ts b/packages/opencode/test/cli/acp/prompt-content.test.ts
similarity index 91%
rename from packages/opencode/test/cli/acp-next/prompt-content.test.ts
rename to packages/opencode/test/cli/acp/prompt-content.test.ts
index 2e658f70ee21..6b6f6ddeb67e 100644
--- a/packages/opencode/test/cli/acp-next/prompt-content.test.ts
+++ b/packages/opencode/test/cli/acp/prompt-content.test.ts
@@ -5,18 +5,18 @@ import { writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { cliIt } from "../../lib/cli-process"
-import { expectOk } from "../acp/acp-test-client"
-import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
+import { expectOk } from "./acp-test-client"
+import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers"
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
-describe("opencode acp-next prompt content subprocess", () => {
+describe("opencode acp prompt content subprocess", () => {
cliIt.live(
"accepts embedded text resource image and file resource link prompt content",
({ home, llm, opencode }) =>
Effect.gen(function* () {
yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n"))
- const acp = yield* createAcpNextClient(
+ const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) },
)
diff --git a/packages/opencode/test/cli/acp-next/skills.test.ts b/packages/opencode/test/cli/acp/skills.test.ts
similarity index 87%
rename from packages/opencode/test/cli/acp-next/skills.test.ts
rename to packages/opencode/test/cli/acp/skills.test.ts
index 0485fa56d439..b423493cfa39 100644
--- a/packages/opencode/test/cli/acp-next/skills.test.ts
+++ b/packages/opencode/test/cli/acp/skills.test.ts
@@ -4,9 +4,9 @@ import { Effect } from "effect"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { cliIt } from "../../lib/cli-process"
-import { createAcpNextClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers"
+import { createAcpClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers"
-describe("opencode acp-next skills subprocess", () => {
+describe("opencode acp skills subprocess", () => {
cliIt.live(
"skill slash command appears through available_commands_update",
({ home, llm, opencode }) =>
@@ -14,7 +14,7 @@ describe("opencode acp-next skills subprocess", () => {
const skills = path.join(home, "skills")
yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill))
- const acp = yield* createAcpNextClient(
+ const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) },
)