feat(acp): promote next implementation (#29929)

This commit is contained in:
Shoubhit Dash
2026-05-30 01:34:44 +05:30
committed by GitHub
parent 0733c080c0
commit 4cc166a400
39 changed files with 499 additions and 4217 deletions
-95
View File
@@ -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<A>(effect: Effect.Effect<A, ACPNextService.Error>) {
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"
-232
View File
@@ -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<string, KnownMessagePartMetadata>
}
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<Info>
readonly load: (input: StoreInput) => Effect.Effect<Info>
readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
readonly get: (sessionId: string) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
readonly setModel: (
sessionId: string,
model: SelectedModel | undefined,
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
readonly getModel: (sessionId: string) => Effect.Effect<SelectedModel | undefined, ACPNextError.SessionNotFoundError>
readonly setVariant: (
sessionId: string,
variant: string | undefined,
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
readonly getVariant: (sessionId: string) => Effect.Effect<string | undefined, ACPNextError.SessionNotFoundError>
readonly setMode: (
sessionId: string,
modeId: string | undefined,
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
readonly getMode: (sessionId: string) => Effect.Effect<string | undefined, ACPNextError.SessionNotFoundError>
readonly recordPartMetadata: (
input: RecordPartMetadataInput,
) => Effect.Effect<KnownMessagePartMetadata, ACPNextError.SessionNotFoundError>
readonly getPartMetadata: (
input: PartMetadataLookupInput,
) => Effect.Effect<KnownMessagePartMetadata | undefined, ACPNextError.SessionNotFoundError>
readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect<KnownMessagePartMetadata | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Session") {}
type State = Map<string, Info>
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Ref.make<State>(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"
-174
View File
@@ -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)
+38 -1909
View File
@@ -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<number | null> {
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<void> {
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<string, string>()
private toolStarts = new Set<string>()
private permissionQueues = new Map<string, Promise<void>>()
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(private readonly service: ACPService.Interface) {}
constructor(connection: AgentSideConnection, config: ACPConfig) {
this.connection = connection
this.config = config
this.sdk = config.sdk
this.sessionManager = new ACPSessionManager(this.sdk)
this.startEventSubscription()
initialize(params: InitializeRequest) {
return run(this.service.initialize(params))
}
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 })
})
authenticate(params: AuthenticateRequest) {
return run(this.service.authenticate(params))
}
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 })
})
}
}
newSession(params: NewSessionRequest) {
return run(this.service.newSession(params))
}
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
}
}
loadSession(params: LoadSessionRequest) {
return run(this.service.loadSession(params))
}
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
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,
},
}
listSessions(params: ListSessionsRequest) {
return run(this.service.listSessions(params))
}
async authenticate(_params: AuthenticateRequest) {
throw new Error("Authentication not implemented")
resumeSession(params: ResumeSessionRequest) {
return run(this.service.resumeSession(params))
}
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
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
}
closeSession(params: CloseSessionRequest) {
return run(this.service.closeSession(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
}
unstable_forkSession(params: ForkSessionRequest) {
return run(this.service.forkSession(params))
}
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
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
}
setSessionConfigOption(params: SetSessionConfigOptionRequest) {
return run(this.service.setSessionConfigOption(params))
}
async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
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
}
setSessionMode(params: SetSessionModeRequest) {
return run(this.service.setSessionMode(params))
}
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
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
}
unstable_setSessionModel(params: SetSessionModelRequest) {
return run(this.service.setSessionModel(params))
}
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
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 {}
prompt(params: PromptRequest) {
return run(this.service.prompt(params))
}
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 })
})
}
}
}
}
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
}
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 })
})
}
private async loadAvailableModes(directory: string): Promise<ModeOption[]> {
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,
}))
}
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 }
}
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<string, ConfigMCP.Info> = {}
for (const server of params.mcpServers) {
if ("type" in server) {
mcpServers[server.name] = {
url: server.url,
headers: server.headers.reduce<Record<string, string>>((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<Record<string, string>>((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,
}),
}
}
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,
}),
}
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
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<SetSessionConfigOptionResponse> {
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)
}
cancel(params: CancelNotification) {
return run(this.service.cancel(params))
}
}
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<string, any>): { 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 } : {}),
}
}
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<A>(effect: Effect.Effect<A, ACPService.Error>) {
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<string, unknown> }>,
): 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<T extends { name: string }>(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<string, { variants?: Record<string, any> }> }>,
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<string, any> }>,
options: { includeVariants?: boolean } = {},
): ModelOption[] {
const includeVariants = options.includeVariants ?? false
return providers.flatMap((provider) => {
const unsorted: Array<{ id: string; name: string; variants?: Record<string, any> }> = 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<string, { variants?: Record<string, any> }> }>,
): { 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"
@@ -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<Snapshot, ACPNextError.Error>
readonly load: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
}
export interface Interface {
readonly get: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
readonly refresh: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
readonly get: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
readonly refresh: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined
}
export class Loader extends Context.Service<Loader, LoaderInterface>()("@opencode/ACPNextDirectoryLoader") {}
export class Loader extends Context.Service<Loader, LoaderInterface>()("@opencode/ACPDirectoryLoader") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNextDirectory") {}
export class Service extends Context.Service<Service, Interface>()("@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<string, Effect.Effect<Snapshot, ACPNextError.Error>>())
const snapshots = yield* SynchronizedRef.make(new Map<string, Effect.Effect<Snapshot, ACPError.Error>>())
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) {
@@ -2,51 +2,51 @@ import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
"ACPNextSessionNotFoundError",
"ACPSessionNotFoundError",
{
sessionId: Schema.String,
},
) {}
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
"ACPNextInvalidConfigOptionError",
"ACPInvalidConfigOptionError",
{
configId: Schema.String,
},
) {}
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPNextInvalidModelError", {
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
modelId: Schema.String,
providerId: Schema.optional(Schema.String),
}) {}
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPNextInvalidEffortError", {
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
effort: Schema.String,
}) {}
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPNextInvalidModeError", {
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
mode: Schema.String,
}) {}
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPNextAuthRequiredError", {
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {
providerId: Schema.optional(Schema.String),
}) {}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPNextUnknownAuthMethodError",
"ACPUnknownAuthMethodError",
{
methodId: Schema.String,
},
) {}
export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
"ACPNextUnsupportedOperationError",
"ACPUnsupportedOperationError",
{
method: Schema.String,
},
) {}
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPNextServiceFailureError", {
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("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)
}
}
@@ -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<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
@@ -32,7 +32,7 @@ type GlobalEventStream = {
stream: AsyncIterable<GlobalEventEnvelope>
}
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<string, string>()
private readonly toolStarts = new Set<string>()
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"
@@ -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<Event, { type: "permission.asked" }>
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"
@@ -39,4 +39,4 @@ function write(name: string, durationMs: number, fields?: Record<string, string
console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`)
}
export * as ACPNextProfile from "./profile"
export * as ACPProfile from "./profile"
-22
View File
@@ -1,22 +0,0 @@
import { Agent } from "@/agent/agent"
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceRuntime } from "@/project/instance-runtime"
import { Effect } from "effect"
// Global ACP Effect re-entry: no project InstanceRef is provided.
export const runGlobal = AppRuntime.runPromise
// Directory-scoped ACP Effect re-entry: load the project instance and provide InstanceRef.
export async function runDirectory<A, E>(input: { directory: string; effect: Effect.Effect<A, E, AppServices> }) {
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"
@@ -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<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
@@ -70,26 +70,26 @@ export type Interface = {
readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Service") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/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<string, Set<string>>()
const sessionSnapshots = new Map<string, Directory.Snapshot>()
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<string, Promise<number | undefined>>()
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<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
}
function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>, 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<T>(value: T | SdkResponse<T>): value is SdkResponse<T> {
}
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")
)
}
+212 -102
View File
@@ -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"
const log = Log.create({ service: "acp-session-manager" })
export type SelectedModel = {
providerID: ProviderID
modelID: ModelID
}
export class ACPSessionManager {
private sessions = new Map<string, ACPSessionState>()
private sdk: OpencodeClient
export type KnownMessagePartMetadata = {
messageId: string
partId: string
partType?: Part["type"]
role?: Message["role"]
ignored?: boolean
toolCallId?: string
metadata?: unknown
}
constructor(sdk: OpencodeClient) {
this.sdk = sdk
}
export type Info = {
id: string
cwd: string
mcpServers: readonly McpServer[]
createdAt: Date
model?: SelectedModel
variant?: string
modeId?: string
knownParts: ReadonlyMap<string, KnownMessagePartMetadata>
}
tryGet(sessionId: string): ACPSessionState | undefined {
return this.sessions.get(sessionId)
}
export type StoreInput = {
id: string
cwd: string
mcpServers?: readonly McpServer[]
createdAt?: Date
model?: SelectedModel
variant?: string
modeId?: string
}
async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise<ACPSessionState> {
const session = await this.sdk.session
.create(
{
directory: cwd,
},
{ throwOnError: true },
)
.then((x) => x.data!)
export type RecordPartMetadataInput = {
sessionId: string
messageId: string
partId: string
partType?: Part["type"]
role?: Message["role"]
ignored?: boolean
toolCallId?: string
metadata?: unknown
}
const sessionId = session.id
const resolvedModel = model
export type PartMetadataLookupInput = {
sessionId: string
messageId: string
partId: string
}
const state: ACPSessionState = {
id: sessionId,
cwd,
mcpServers,
createdAt: new Date(),
model: resolvedModel,
}
log.info("creating_session", { state })
this.sessions.set(sessionId, state)
return state
}
async load(
export type Interface = {
readonly create: (input: StoreInput) => Effect.Effect<Info>
readonly load: (input: StoreInput) => Effect.Effect<Info>
readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
readonly get: (sessionId: string) => Effect.Effect<Info, ACPError.SessionNotFoundError>
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
readonly setModel: (
sessionId: string,
cwd: string,
mcpServers: McpServer[],
model?: ACPSessionState["model"],
): Promise<ACPSessionState> {
const session = await this.sdk.session
.get(
{
sessionID: sessionId,
directory: cwd,
},
{ throwOnError: true },
)
.then((x) => x.data!)
model: SelectedModel | undefined,
) => Effect.Effect<Info, ACPError.SessionNotFoundError>
readonly getModel: (sessionId: string) => Effect.Effect<SelectedModel | undefined, ACPError.SessionNotFoundError>
readonly setVariant: (
sessionId: string,
variant: string | undefined,
) => Effect.Effect<Info, ACPError.SessionNotFoundError>
readonly getVariant: (sessionId: string) => Effect.Effect<string | undefined, ACPError.SessionNotFoundError>
readonly setMode: (
sessionId: string,
modeId: string | undefined,
) => Effect.Effect<Info, ACPError.SessionNotFoundError>
readonly getMode: (sessionId: string) => Effect.Effect<string | undefined, ACPError.SessionNotFoundError>
readonly recordPartMetadata: (
input: RecordPartMetadataInput,
) => Effect.Effect<KnownMessagePartMetadata, ACPError.SessionNotFoundError>
readonly getPartMetadata: (
input: PartMetadataLookupInput,
) => Effect.Effect<KnownMessagePartMetadata | undefined, ACPError.SessionNotFoundError>
readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect<KnownMessagePartMetadata | undefined>
}
const resolvedModel = model
export class Service extends Context.Service<Service, Interface>()("@opencode/ACP/Session") {}
const state: ACPSessionState = {
id: sessionId,
cwd,
mcpServers,
createdAt: new Date(session.time.created),
model: resolvedModel,
}
log.info("loading_session", { state })
type State = Map<string, Info>
this.sessions.set(sessionId, state)
return state
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Ref.make<State>(new Map())
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
}
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)
})
getModel(sessionId: string) {
const session = this.get(sessionId)
return session.model
}
const tryGet = Effect.fn("ACP.Session.tryGet")(function* (sessionId: string) {
const session = (yield* Ref.get(sessions)).get(sessionId)
if (!session) return
return snapshot(session)
})
setModel(sessionId: string, model: ACPSessionState["model"]) {
const session = this.get(sessionId)
session.model = model
this.sessions.set(sessionId, session)
return 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 })
})
getVariant(sessionId: string) {
const session = this.get(sessionId)
return session.variant
}
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 })
})
setVariant(sessionId: string, variant?: string) {
const session = this.get(sessionId)
session.variant = variant
this.sessions.set(sessionId, session)
return session
}
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
})
})
setMode(sessionId: string, modeId: string) {
const session = this.get(sessionId)
session.modeId = modeId
this.sessions.set(sessionId, session)
return session
}
const setModel: Interface["setModel"] = Effect.fn("ACP.Session.setModel")((sessionId, model) =>
update(sessionId, (session) => ({ ...session, model })),
)
remove(sessionId: string): ACPSessionState | undefined {
const session = this.sessions.get(sessionId)
this.sessions.delete(sessionId)
return session
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(),
}
}
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 ACPSession from "./session"
-24
View File
@@ -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
}
}
@@ -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<OpenCodeAssistantMessage, "cost" | "tokens">
@@ -60,14 +60,14 @@ export interface Interface {
}
export class MessageLoader extends Context.Service<MessageLoader, MessageLoaderInterface>()(
"@opencode/ACPNextUsageMessageLoader",
"@opencode/ACPUsageMessageLoader",
) {}
export class ContextLimitLoader extends Context.Service<ContextLimitLoader, ContextLimitLoaderInterface>()(
"@opencode/ACPNextUsageContextLimitLoader",
"@opencode/ACPUsageContextLimitLoader",
) {}
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNextUsage") {}
export class Service extends Context.Service<Service, Interface>()("@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
+6 -11
View File
@@ -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")
@@ -50,7 +50,6 @@ export class Service extends ConfigService.Service<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"),