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
-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)
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
export const DEFAULT_VARIANT_VALUE = "default"
export type ConfigOptionModel = {
id: string
name: string
variants?: Record<string, Record<string, unknown>>
}
export type ConfigOptionProvider = {
id: string
name: string
models: Record<string, ConfigOptionModel>
}
export type ConfigOptionMode = {
id: string
name: string
description?: string
}
export type ModelSelection = {
model: {
providerID: string
modelID: string
}
variant?: string
}
export function buildModelSelectOption(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
currentVariant?: string
includeVariants?: boolean
}): SessionConfigOption {
return {
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: formatCurrentModelId({
model: input.currentModel,
variant: input.currentVariant,
variants: variantsForModel(input.providers, input.currentModel),
includeVariant: input.includeVariants ?? false,
}),
options: buildModelSelectOptions(input.providers, { includeVariants: input.includeVariants ?? false }),
}
}
export function buildEffortSelectOption(input: {
variants: readonly string[]
currentVariant?: string
}): SessionConfigOption | undefined {
if (input.variants.length === 0) return undefined
return {
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: input.variants.map((variant) => ({
value: variant,
name: formatVariantName(variant),
})),
}
}
export function buildModeSelectOption(input: {
modes: readonly ConfigOptionMode[]
currentModeId: string
}): SessionConfigOption {
return {
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: input.currentModeId,
options: input.modes.map((mode) => ({
value: mode.id,
name: mode.name,
...(mode.description ? { description: mode.description } : {}),
})),
}
}
export function buildConfigOptions(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
currentVariant?: string
includeModelVariants?: boolean
modes?: readonly ConfigOptionMode[]
currentModeId?: string
}): SessionConfigOption[] {
const variants = variantsForModel(input.providers, input.currentModel)
const effort = buildEffortSelectOption({ variants, currentVariant: input.currentVariant })
return [
buildModelSelectOption({
providers: input.providers,
currentModel: input.currentModel,
currentVariant: input.currentVariant,
includeVariants: input.includeModelVariants ?? false,
}),
...(effort ? [effort] : []),
...(input.modes && input.currentModeId
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
: []),
]
}
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
if (provider) {
const modelID = modelId.slice(provider.id.length + 1)
if (provider.models[modelID]) {
return { model: { providerID: provider.id, modelID } }
}
const separator = modelID.lastIndexOf("/")
if (separator > -1) {
const baseModelID = modelID.slice(0, separator)
const variant = modelID.slice(separator + 1)
if (provider.models[baseModelID]?.variants?.[variant]) {
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
}
}
return { model: { providerID: provider.id, modelID } }
}
const separator = modelId.indexOf("/")
if (separator === -1) {
return { model: { providerID: modelId, modelID: "" } }
}
return {
model: {
providerID: modelId.slice(0, separator),
modelID: modelId.slice(separator + 1),
},
}
}
export function formatCurrentModelId(input: {
model: ModelSelection["model"]
variant?: string
variants?: readonly string[]
includeVariant?: boolean
}) {
const base = `${input.model.providerID}/${input.model.modelID}`
if (!input.includeVariant || !input.variants?.length) return base
return `${base}/${selectVariant(input.variant, input.variants)}`
}
export function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}
function buildModelSelectOptions(
providers: readonly ConfigOptionProvider[],
options: { includeVariants: boolean },
): Array<{ value: string; name: string }> {
return providers.flatMap((provider) =>
Object.values(provider.models)
.sort((a, b) => a.name.localeCompare(b.name))
.flatMap((model) => {
const base = {
value: `${provider.id}/${model.id}`,
name: `${provider.name}/${model.name}`,
}
if (!options.includeVariants || !model.variants) return [base]
return [
base,
...Object.keys(model.variants)
.filter((variant) => variant !== DEFAULT_VARIANT_VALUE)
.map((variant) => ({
value: `${provider.id}/${model.id}/${variant}`,
name: `${provider.name}/${model.name} (${formatVariantName(variant)})`,
})),
]
}),
)
}
function variantsForModel(providers: readonly ConfigOptionProvider[], model: ModelSelection["model"]) {
return Object.keys(
providers.find((provider) => provider.id === model.providerID)?.models[model.modelID]?.variants ?? {},
)
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0]
}
+250
View File
@@ -0,0 +1,250 @@
import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk"
import path from "node:path"
import { pathToFileURL } from "node:url"
import type { MessageV2 } from "@/session/message-v2"
export type PromptPart = MessageV2.TextPartInput | MessageV2.FilePartInput
export type ReplayPart =
| {
type: "text"
text: string
synthetic?: boolean
ignored?: boolean
}
| {
type: "file"
url: string
mime: string
filename?: string
}
| {
type: "reasoning"
text: string
}
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
return content.flatMap(contentBlockToParts)
}
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
switch (block.type) {
case "text":
return [
{
type: "text",
text: block.text,
...audienceFlags(block.annotations?.audience ?? undefined),
},
]
case "image":
if (block.data) {
return [
{
type: "file",
url: `data:${block.mimeType};base64,${block.data}`,
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
mime: block.mimeType,
},
]
}
if (block.uri?.startsWith("data:")) {
return [
{
type: "file",
url: block.uri,
filename: filenameFromUri(block.uri) ?? "image",
mime: block.mimeType,
},
]
}
if (block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
return [
{
type: "file",
url: block.uri,
filename: filenameFromUri(block.uri) ?? "image",
mime: block.mimeType,
},
]
}
return []
case "resource_link":
return [resourceLinkToPart(block)]
case "resource":
if ("text" in block.resource) {
return [{ type: "text", text: block.resource.text }]
}
if (block.resource.mimeType) {
return [
{
type: "file",
url: block.resource.uri.startsWith("data:")
? block.resource.uri
: `data:${block.resource.mimeType};base64,${block.resource.blob}`,
filename: filenameFromUri(block.resource.uri) ?? "file",
mime: block.resource.mimeType,
},
]
}
return []
default:
return []
}
}
export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
return parts.flatMap(partToContentChunks)
}
export function partToContentChunks(part: ReplayPart): ContentChunk[] {
switch (part.type) {
case "text":
if (!part.text) return []
return [
{
content: {
type: "text",
text: part.text,
...partAudience(part),
},
},
]
case "file":
return filePartToContentChunks(part)
case "reasoning":
if (!part.text) return []
return [
{
content: {
type: "text",
text: part.text,
},
},
]
}
}
function resourceLinkToPart(link: ResourceLink): PromptPart {
const parsed = uriToFilePart(link.uri, link.mimeType ?? "text/plain", link.name)
if (parsed.type === "file") return parsed
return { type: "text", text: parsed.text }
}
function uriToFilePart(
uri: string,
mime: string,
filename?: string,
): MessageV2.FilePartInput | MessageV2.TextPartInput {
try {
if (uri.startsWith("file://")) {
return {
type: "file",
url: uri,
filename: filename ?? filenameFromUri(uri) ?? "file",
mime,
}
}
if (uri.startsWith("zed://")) {
const pathname = new URL(uri).searchParams.get("path")
if (pathname) {
return {
type: "file",
url: pathToFileURL(pathname).href,
filename: filename ?? (path.basename(pathname) || "file"),
mime,
}
}
}
return { type: "text", text: uri }
} catch {
return { type: "text", text: uri }
}
}
function filePartToContentChunks(part: Extract<ReplayPart, { type: "file" }>): ContentChunk[] {
if (part.url.startsWith("file://")) {
return [
{
content: {
type: "resource_link",
uri: part.url,
name: part.filename ?? "file",
mimeType: part.mime,
},
},
]
}
if (!part.url.startsWith("data:")) return []
const data = decodeDataUrl(part.url)
if (!data) return []
if (data.mime.startsWith("image/")) {
return [
{
content: {
type: "image",
mimeType: data.mime,
data: data.base64,
uri: pathToFileURL(part.filename ?? "image").href,
},
},
]
}
return [
{
content: {
type: "resource",
resource:
data.mime.startsWith("text/") || data.mime === "application/json"
? {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: data.mime,
text: Buffer.from(data.base64, "base64").toString("utf8"),
}
: {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: data.mime,
blob: data.base64,
},
},
},
]
}
function decodeDataUrl(url: string) {
const match = /^data:([^;]+);base64,(.*)$/.exec(url)
if (!match) return
return { mime: match[1], base64: match[2] }
}
function audienceFlags(audience: readonly Role[] | null | undefined) {
if (audience?.length === 1 && audience[0] === "assistant") return { synthetic: true }
if (audience?.length === 1 && audience[0] === "user") return { ignored: true }
return {}
}
function partAudience(part: Extract<ReplayPart, { type: "text" }>) {
const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
if (!audience) return {}
return { annotations: { audience } }
}
function filenameFromUri(uri: string | undefined) {
if (!uri) return
if (uri.startsWith("data:")) return
try {
const parsed = new URL(uri)
const name = path.basename(parsed.pathname)
return name || undefined
} catch {
return path.basename(uri) || undefined
}
}
+209
View File
@@ -0,0 +1,209 @@
import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import { InstanceRef } from "@/effect/instance-ref"
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 ACPError from "./error"
export type ModelOption = {
readonly providerID: ProviderID
readonly providerName: string
readonly modelID: ModelID
readonly modelName: string
}
export type ModeOption = {
readonly id: string
readonly name: string
readonly description?: string
}
export type ModelVariants = NonNullable<Provider.Model["variants"]>
export type DefaultModel = {
readonly providerID: ProviderID
readonly modelID: ModelID
}
export type Snapshot = {
readonly directory: string
readonly providers: Record<ProviderID, Provider.Info>
readonly modelOptions: readonly ModelOption[]
readonly variantsByModel: Readonly<Record<string, ModelVariants>>
readonly availableModes: readonly ModeOption[]
readonly defaultModeID: string
readonly availableCommands: readonly Command.Info[]
readonly defaultModel?: DefaultModel
}
export interface LoaderInterface {
readonly load: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
}
export interface Interface {
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/ACPDirectoryLoader") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPDirectory") {}
export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}`
export const variants = (snapshot: Snapshot, model: DefaultModel) => snapshot.variantsByModel[modelKey(model)]
export const build = (input: {
readonly directory: string
readonly providers: Record<ProviderID, Provider.Info>
readonly modes: readonly ModeOption[]
readonly defaultModeID: string
readonly commands: readonly Command.Info[]
readonly defaultModel?: DefaultModel
}): Snapshot => {
const modelOptions = Provider.sort(
Object.values(input.providers).flatMap((provider) =>
Object.values(provider.models).map((model) => ({
id: model.id,
providerID: provider.id,
providerName: provider.name,
modelID: model.id,
modelName: model.name,
})),
),
).map((model) => ({
providerID: model.providerID,
providerName: model.providerName,
modelID: model.modelID,
modelName: model.modelName,
}))
return {
directory: input.directory,
providers: input.providers,
modelOptions,
variantsByModel: Object.fromEntries(
Object.values(input.providers).flatMap((provider) =>
Object.values(provider.models).flatMap((model) =>
model.variants ? [[modelKey({ providerID: provider.id, modelID: model.id }), model.variants]] : [],
),
),
),
availableModes: input.modes,
defaultModeID: input.modes.some((mode) => mode.id === input.defaultModeID)
? input.defaultModeID
: (input.modes[0]?.id ?? input.defaultModeID),
availableCommands: input.commands,
...(input.defaultModel ? { defaultModel: input.defaultModel } : {}),
}
}
export const loaderLayer = Layer.effect(
Loader,
Effect.gen(function* () {
const store = yield* InstanceStore.Service
const provider = yield* Provider.Service
const agent = yield* Agent.Service
const command = yield* Command.Service
return Loader.of({
load: Effect.fn("ACPDirectoryLoader.load")(function* (directory) {
const ctx = yield* store.load({ directory })
return yield* Effect.gen(function* () {
const providers = yield* provider.list()
const [agents, defaultAgent, commands, defaultModel] = yield* Effect.all(
[agent.list(), agent.defaultInfo(), command.list(), provider.defaultModel().pipe(Effect.option)],
{ concurrency: "unbounded" },
)
return build({
directory,
providers,
modes: agents
.filter((item) => item.mode !== "subagent" && item.hidden !== true)
.map((item) => ({
id: item.name,
name: item.name,
...(item.description ? { description: item.description } : {}),
})),
defaultModeID: defaultAgent.name,
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
...(defaultModel._tag === "Some" ? { defaultModel: defaultModel.value } : {}),
})
}).pipe(Effect.provideService(InstanceRef, ctx))
}),
})
}),
)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const loader = yield* Loader
const snapshots = yield* SynchronizedRef.make(new Map<string, Effect.Effect<Snapshot, ACPError.Error>>())
const cached = Effect.fnUntraced(function* (directory: string) {
return yield* SynchronizedRef.modifyEffect(
snapshots,
Effect.fnUntraced(function* (items) {
const current = items.get(directory)
if (current) return [current, items] as const
const next = yield* Effect.cached(
loader.load(directory).pipe(
Effect.tapError(() =>
SynchronizedRef.update(snapshots, (state) => {
const next = new Map(state)
next.delete(directory)
return next
}),
),
),
)
return [next, new Map(items).set(directory, next)] as const
}),
)
})
const get = Effect.fn("ACPDirectory.get")(function* (directory: string) {
return yield* yield* cached(directory)
})
const refresh = Effect.fn("ACPDirectory.refresh")(function* (directory: string) {
return yield* SynchronizedRef.modifyEffect(
snapshots,
Effect.fnUntraced(function* (items) {
const next = yield* Effect.cached(
loader.load(directory).pipe(
Effect.tapError(() =>
SynchronizedRef.update(snapshots, (state) => {
const next = new Map(state)
next.delete(directory)
return next
}),
),
),
)
return [next, new Map(items).set(directory, next)] as const
}),
).pipe(Effect.flatten)
})
return Service.of({
get,
refresh,
variants,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(loaderLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(InstanceStore.defaultLayer),
)
export * as Directory from "./directory"
+93
View File
@@ -0,0 +1,93 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
"ACPSessionNotFoundError",
{
sessionId: Schema.String,
},
) {}
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
"ACPInvalidConfigOptionError",
{
configId: Schema.String,
},
) {}
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
modelId: Schema.String,
providerId: Schema.optional(Schema.String),
}) {}
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
effort: Schema.String,
}) {}
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
mode: Schema.String,
}) {}
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {
providerId: Schema.optional(Schema.String),
}) {}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPUnknownAuthMethodError",
{
methodId: Schema.String,
},
) {}
export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
"ACPUnsupportedOperationError",
{
method: Schema.String,
},
) {}
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
}) {}
export type Error =
| SessionNotFoundError
| InvalidConfigOptionError
| InvalidModelError
| InvalidEffortError
| InvalidModeError
| AuthRequiredError
| UnknownAuthMethodError
| UnsupportedOperationError
| ServiceFailureError
export function toRequestError(error: Error) {
switch (error._tag) {
case "ACPSessionNotFoundError":
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
case "ACPInvalidConfigOptionError":
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
case "ACPInvalidModelError":
return RequestError.invalidParams(
{ providerId: error.providerId, modelId: error.modelId },
`model not found: ${error.modelId}`,
)
case "ACPInvalidEffortError":
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
case "ACPInvalidModeError":
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
case "ACPAuthRequiredError":
return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required")
case "ACPUnknownAuthMethodError":
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
case "ACPUnsupportedOperationError":
return RequestError.methodNotFound(error.method)
case "ACPServiceFailureError":
return RequestError.internalError({ service: error.service }, error.safeMessage)
}
}
export function fromUnknownDefect(_defect: unknown, safeMessage = "Internal service failure") {
return new ServiceFailureError({ safeMessage })
}
+319
View File
@@ -0,0 +1,319 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type {
Event,
EventMessagePartDelta,
EventMessagePartUpdated,
OpencodeClient,
Part,
SessionMessageResponse,
ToolPart,
} from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { ACPSession } from "./session"
import { ACPPermission } from "./permission"
import {
duplicateRunningToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
shellOutputSnapshot,
completedToolUpdate,
} from "./tool"
const log = Log.create({ service: "acp-event" })
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
type GlobalEventEnvelope = {
payload?: Event
}
type GlobalEventStream = {
stream: AsyncIterable<GlobalEventEnvelope>
}
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) {
const subscription = new Subscription(input)
subscription.start()
return subscription
}
export class Subscription {
private readonly abort = new AbortController()
private readonly shellSnapshots = new Map<string, string>()
private readonly toolStarts = new Set<string>()
private readonly permission: ACPPermission.Handler
private started = false
constructor(
private readonly input: {
sdk: OpencodeClient
connection: Connection
session: ACPSession.Interface
},
) {
this.permission = new ACPPermission.Handler(input)
}
start() {
if (this.started) return
this.started = true
this.run().catch((error: unknown) => {
if (this.abort.signal.aborted) return
log.error("event subscription failed", { error })
})
}
stop() {
this.abort.abort()
}
async handle(event: Event) {
switch (event.type) {
case "permission.asked":
this.permission.handle(event)
return
case "message.part.updated":
return this.handlePartUpdated(event)
case "message.part.delta":
return this.handlePartDelta(event)
}
}
async replayMessage(message: SessionMessageResponse) {
if (message.info.role !== "assistant" && message.info.role !== "user") return
for (const part of message.parts) {
await this.recordFetchedPart(message.info.sessionID, message, part)
if (part.type === "tool") {
await this.handleToolPart(message.info.sessionID, part)
}
}
}
private async run() {
while (!this.abort.signal.aborted) {
const events = (await this.input.sdk.global.event({
signal: this.abort.signal,
})) as GlobalEventStream
for await (const event of events.stream) {
if (this.abort.signal.aborted) return
if (!event.payload) continue
await this.handle(event.payload).catch((error: unknown) => {
log.error("failed to handle event", { error, type: event.payload?.type })
})
}
if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
private async handlePartUpdated(event: EventMessagePartUpdated) {
const part = event.properties.part
const sessionId = part.sessionID || event.properties.sessionID
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
if (!session) return
await Effect.runPromise(
this.input.session.recordPartMetadata({
sessionId: session.id,
messageId: part.messageID,
partId: part.id,
partType: part.type,
role: part.type === "reasoning" ? "assistant" : undefined,
ignored: part.type === "text" ? part.ignored : undefined,
toolCallId: part.type === "tool" ? part.callID : undefined,
metadata: "metadata" in part ? part.metadata : undefined,
}),
)
if (part.type === "tool") {
await this.handleToolPart(session.id, part)
}
}
private async handlePartDelta(event: EventMessagePartDelta) {
const props = event.properties
const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID))
if (!session) return
const known = await Effect.runPromise(
this.input.session.tryGetPartMetadata({
sessionId: session.id,
messageId: props.messageID,
partId: props.partID,
}),
)
const metadata =
known?.role && known.partType
? known
: await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID)
if (metadata?.role !== "assistant") return
if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) {
await this.input.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "agent_message_chunk",
messageId: props.messageID,
content: {
type: "text",
text: props.delta,
},
},
})
return
}
if (metadata.partType === "reasoning" && props.field === "text") {
await this.input.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: props.messageID,
content: {
type: "text",
text: props.delta,
},
},
})
}
}
private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) {
const message = await this.input.sdk.session
.message(
{
sessionID: sessionId,
messageID: messageId,
directory: cwd,
},
{ throwOnError: true },
)
.then((response) => response.data)
.catch((error: unknown) => {
log.error("unexpected error when fetching message for delta metadata", { error, messageId, partId })
return undefined
})
if (!message) return
const part = message.parts.find((item) => item.id === partId)
if (!part) return
return await this.recordFetchedPart(sessionId, message, part)
}
private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) {
return await Effect.runPromise(
this.input.session.recordPartMetadata({
sessionId,
messageId: part.messageID,
partId: part.id,
partType: part.type,
role: message.info.role,
ignored: part.type === "text" ? part.ignored : undefined,
toolCallId: part.type === "tool" ? part.callID : undefined,
metadata: "metadata" in part ? part.metadata : undefined,
}),
)
}
private async handleToolPart(sessionId: string, part: ToolPart) {
await this.toolStart(sessionId, part)
switch (part.state.status) {
case "pending":
this.shellSnapshots.delete(part.callID)
return
case "running":
await this.runningTool(sessionId, part)
return
case "completed":
this.clearTool(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
return
case "error":
this.clearTool(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
return
}
}
private async runningTool(sessionId: string, part: ToolPart) {
if (part.state.status !== "running") return
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
if (output !== undefined) {
if (this.shellSnapshots.get(part.callID) === output) {
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...duplicateRunningToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
return
}
this.shellSnapshots.set(part.callID, output)
}
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
output,
}),
},
})
}
private async toolStart(sessionId: string, part: ToolPart) {
if (this.toolStarts.has(part.callID)) return
this.toolStarts.add(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: part.callID,
toolName: part.tool,
}),
},
})
}
private clearTool(toolCallId: string) {
this.toolStarts.delete(toolCallId)
this.shellSnapshots.delete(toolCallId)
}
}
export * as ACPEvent from "./event"
+145
View File
@@ -0,0 +1,145 @@
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
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 { ACPSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect"
const log = Log.create({ service: "acp-permission" })
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type Reply = "once" | "always" | "reject"
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
const 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" },
]
export class Handler {
private readonly queues = new Map<string, Promise<void>>()
constructor(
private readonly input: {
sdk: OpencodeClient
connection: Connection
session: ACPSession.Interface
},
) {}
handle(event: PermissionEvent) {
const permission = event.properties
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
const next = previous
.then(() => this.process(event))
.catch((error: unknown) => {
log.error("failed to handle permission", { error, permissionID: permission.id })
})
.finally(() => {
if (this.queues.get(permission.sessionID) === next) {
this.queues.delete(permission.sessionID)
}
})
this.queues.set(permission.sessionID, next)
}
private async process(event: PermissionEvent) {
const permission = event.properties
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
if (!session) return
if (!this.input.connection.requestPermission) {
log.error("ACP connection cannot request permission", {
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return
}
const result = await this.input.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: permissionOptions,
})
.catch(async (error: unknown) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return undefined
})
if (!result) return
const reply = selectedReply(result)
if (reply !== "once" && reply !== "always") {
await this.reply(permission.id, "reject", session.cwd)
return
}
if (permission.permission === "edit") {
await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => {
log.error("failed to write proposed edit through ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
})
}
await this.reply(permission.id, reply, session.cwd)
}
private async reply(requestID: string, reply: Reply, directory: string) {
await this.input.sdk.permission.reply({
requestID,
reply,
directory,
})
}
private async writeProposedEdit(sessionId: string, metadata: ToolInput) {
const filepath = stringValue(metadata.filepath)
const diff = stringValue(metadata.diff)
if (!filepath || !diff || !this.input.connection.writeTextFile) return
const content = (await exists(filepath)) ? await readText(filepath) : ""
const next = applyPatch(content, diff)
if (next === false) {
log.error("Failed to apply unified diff (context mismatch)")
return
}
void this.input.connection.writeTextFile({
sessionId,
path: filepath,
content: next,
})
}
}
function selectedReply(result: RequestPermissionResponse): Reply {
if (result.outcome.outcome !== "selected") return "reject"
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
return "reject"
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
export * as ACPPermission from "./permission"
+42
View File
@@ -0,0 +1,42 @@
const enabled = process.env.OPENCODE_ACP_PROFILE === "1"
const started = performance.now()
export function mark(name: string, fields?: Record<string, string | number | boolean | undefined>) {
if (!enabled) return
write(`${name}.mark`, performance.now() - started, fields)
}
export function duration(
name: string,
startedAt: number,
fields?: Record<string, string | number | boolean | undefined>,
) {
if (!enabled) return
write(name, performance.now() - startedAt, fields)
}
export async function measure<T>(
name: string,
fn: () => Promise<T>,
fields?: Record<string, string | number | boolean | undefined>,
) {
if (!enabled) return fn()
const start = performance.now()
try {
return await fn()
} finally {
write(name, performance.now() - start, fields)
}
}
function write(name: string, durationMs: number, fields?: Record<string, string | number | boolean | undefined>) {
const extra = fields
? Object.entries(fields)
.filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined)
.map(([key, value]) => `${key}=${value}`)
.join(" ")
: ""
console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`)
}
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"
File diff suppressed because it is too large Load Diff
+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"
+290
View File
@@ -0,0 +1,290 @@
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
export type ToolInput = Record<string, unknown>
export type ToolAttachment = {
readonly mime?: string
readonly url?: string
readonly [key: string]: unknown
}
export type CompletedToolState = {
readonly status: "completed"
readonly input: ToolInput
readonly output: string
readonly metadata?: unknown
readonly attachments?: ReadonlyArray<ToolAttachment>
}
export type RunningToolState = {
readonly status: "running"
readonly input: ToolInput
readonly title?: string
}
export type ErrorToolState = {
readonly status: "error"
readonly input: ToolInput
readonly error: string
readonly metadata?: unknown
}
export type ImageAttachment = {
readonly mimeType: string
readonly data: string
}
export function toToolKind(toolName: string): ToolKind {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "bash":
case "shell":
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 "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return "search"
case "read":
return "read"
default:
return "other"
}
}
export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "read":
case "edit":
case "write":
return locationFrom(input.filePath ?? input.filepath)
case "grep":
case "glob":
case "repo_clone":
case "repo_overview":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return locationFrom(input.path)
case "bash":
case "shell":
return []
default:
return []
}
}
export function completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] {
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: state.output,
},
},
]
if (toToolKind(toolName) === "edit") {
content.push(...diffContent(state.input))
}
content.push(...imageContents(state.attachments ?? []))
return content
}
export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall {
return {
toolCallId: input.toolCallId,
title: input.toolName,
kind: toToolKind(input.toolName),
status: "pending",
locations: [],
rawInput: {},
}
}
export function runningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: RunningToolState
readonly output?: string
}): ToolCallUpdate {
const content = input.output
? [
{
type: "content" as const,
content: {
type: "text" as const,
text: input.output,
},
},
]
: undefined
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: input.state.title ?? input.toolName,
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
...(content ? { content } : {}),
}
}
export function duplicateRunningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: RunningToolState
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: input.state.title ?? input.toolName,
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
}
}
export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: CompletedToolState & { readonly title: string }
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "completed",
kind: toToolKind(input.toolName),
title: input.state.title,
content: completedToolContent(input.toolName, input.state),
rawInput: input.state.input,
rawOutput: completedToolRawOutput(input.state),
}
}
export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: ErrorToolState
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
title: input.toolName,
rawInput: input.state.input,
content: [
{
type: "content",
content: {
type: "text",
text: input.state.error,
},
},
],
rawOutput: {
error: input.state.error,
metadata: input.state.metadata,
},
}
}
export function completedToolRawOutput(state: CompletedToolState) {
return {
output: state.output,
...(state.metadata !== undefined ? { metadata: state.metadata } : {}),
...(state.attachments?.length ? { attachments: state.attachments } : {}),
}
}
export function imageContents(attachments: ReadonlyArray<ToolAttachment>): ToolCallContent[] {
return extractImageAttachments(attachments).map((attachment): ToolCallContent => {
return {
type: "content",
content: {
type: "image",
mimeType: attachment.mimeType,
data: attachment.data,
},
}
})
}
export function extractImageAttachments(attachments: ReadonlyArray<ToolAttachment>): ImageAttachment[] {
return attachments.flatMap((attachment): ImageAttachment[] => {
const data = dataUrlImage(attachment)
return data ? [data] : []
})
}
export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
if (!state.metadata || typeof state.metadata !== "object") return undefined
return stringValue((state.metadata as Record<string, unknown>).output)
}
export const mapToolKind = toToolKind
export const extractLocations = toLocations
export const buildCompletedToolContent = completedToolContent
export const buildCompletedRawOutput = completedToolRawOutput
export const extractShellOutputSnapshot = shellOutputSnapshot
export const buildPendingToolCall = pendingToolCall
export const buildRunningToolUpdate = runningToolUpdate
export const buildDuplicateRunningToolUpdate = duplicateRunningToolUpdate
export const buildCompletedToolUpdate = completedToolUpdate
export const buildErrorToolUpdate = errorToolUpdate
function locationFrom(value: unknown): ToolCallLocation[] {
const path = stringValue(value)
return path ? [{ path }] : []
}
function diffContent(input: ToolInput): ToolCallContent[] {
const oldText = stringValue(input.oldString)
const newText = stringValue(input.newString) ?? stringValue(input.content)
if (oldText === undefined || newText === undefined) return []
return [
{
type: "diff",
path: stringValue(input.filePath) ?? "",
oldText,
newText,
},
]
}
function dataUrlImage(attachment: ToolAttachment) {
const match = stringValue(attachment.url)?.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/)
const mime = match?.[1] ?? stringValue(attachment.mime)
if (!mime?.startsWith("image/")) return undefined
const data = match?.[2]
if (data === undefined) return undefined
return { mimeType: mime, data }
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
-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
}
}
+238
View File
@@ -0,0 +1,238 @@
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
const log = Log.create({ service: "acp-usage" })
export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens">
export type AssistantMessage = AssistantTokenCost &
Pick<OpenCodeAssistantMessage, "role"> &
Partial<Pick<OpenCodeAssistantMessage, "providerID" | "modelID">>
export type SessionMessage = {
readonly info: { readonly role: Message["role"] } | AssistantMessage
}
export type MessagesInput = {
readonly sessionID: string
readonly directory: string
}
export type SDK = {
readonly session: {
readonly messages: (
parameters: { readonly sessionID: string; readonly directory: string },
options: { readonly throwOnError: true },
) => Promise<{ readonly data?: readonly SessionMessage[] | null }>
}
}
export interface MessageLoaderInterface {
readonly messages: (input: MessagesInput) => Effect.Effect<readonly SessionMessage[], unknown>
}
export interface ContextLimitLoaderInterface {
readonly providers: (directory: string) => Effect.Effect<Record<ProviderID, Provider.Info>, unknown>
}
export type UsageConnection = Pick<AgentSideConnection, "sessionUpdate">
export interface Interface {
readonly buildUsage: (message: AssistantTokenCost) => Usage
readonly latestAssistantMessage: (messages: readonly SessionMessage[]) => AssistantMessage | undefined
readonly totalSessionCost: (messages: readonly SessionMessage[]) => number
readonly contextLimit: (input: {
readonly directory: string
readonly providerID: ProviderID
readonly modelID: ModelID
}) => Effect.Effect<number | undefined>
readonly sendUpdate: (input: {
readonly connection: UsageConnection
readonly sessionID: string
readonly directory: string
}) => Effect.Effect<void>
}
export class MessageLoader extends Context.Service<MessageLoader, MessageLoaderInterface>()(
"@opencode/ACPUsageMessageLoader",
) {}
export class ContextLimitLoader extends Context.Service<ContextLimitLoader, ContextLimitLoaderInterface>()(
"@opencode/ACPUsageContextLimitLoader",
) {}
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPUsage") {}
export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface {
return MessageLoader.of({
messages: (input) =>
Effect.promise(() =>
sdk.session
.messages({ sessionID: input.sessionID, directory: input.directory }, { throwOnError: true })
.then((response) => response.data ?? []),
),
})
}
export const messageLoaderLayer = (sdk: SDK) => Layer.succeed(MessageLoader, messageLoaderFromSDK(sdk))
export function buildUsage(message: AssistantTokenCost): Usage {
const cachedReadTokens = message.tokens.cache.read
const cachedWriteTokens = message.tokens.cache.write
const thoughtTokens = message.tokens.reasoning
return {
inputTokens: message.tokens.input,
outputTokens: message.tokens.output,
totalTokens: message.tokens.input + message.tokens.output + thoughtTokens + cachedReadTokens + cachedWriteTokens,
...(thoughtTokens > 0 ? { thoughtTokens } : {}),
...(cachedReadTokens > 0 ? { cachedReadTokens } : {}),
...(cachedWriteTokens > 0 ? { cachedWriteTokens } : {}),
}
}
export function latestAssistantMessage(messages: readonly SessionMessage[]): AssistantMessage | undefined {
return messages
.filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant")
.at(-1)?.info
}
export function totalSessionCost(messages: readonly SessionMessage[]): number {
return messages
.filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant")
.reduce((sum, message) => sum + message.info.cost, 0)
}
export function findContextLimit(
providers: Record<ProviderID, Provider.Info>,
providerID: ProviderID,
modelID: ModelID,
): number | undefined {
return providers[providerID]?.models[modelID]?.limit.context
}
export const contextLimitLoaderLayer = Layer.effect(
ContextLimitLoader,
Effect.gen(function* () {
const store = yield* InstanceStore.Service
const provider = yield* Provider.Service
return ContextLimitLoader.of({
providers: Effect.fn("ACPUsageContextLimitLoader.providers")(function* (directory) {
const ctx = yield* store.load({ directory })
return yield* Effect.gen(function* () {
return yield* provider.list()
}).pipe(Effect.provideService(InstanceRef, ctx))
}),
})
}),
)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const messageLoader = yield* MessageLoader
const contextLimitLoader = yield* ContextLimitLoader
const limits = yield* SynchronizedRef.make(new Map<string, Effect.Effect<number | undefined>>())
const cachedLimit = Effect.fnUntraced(function* (input: {
readonly directory: string
readonly providerID: ProviderID
readonly modelID: ModelID
}) {
return yield* SynchronizedRef.modifyEffect(
limits,
Effect.fnUntraced(function* (items) {
const key = `${input.directory}\u0000${input.providerID}\u0000${input.modelID}`
const current = items.get(key)
if (current) return [current, items] as const
const next = yield* Effect.cached(
contextLimitLoader.providers(input.directory).pipe(
Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)),
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to get providers for usage context limit", { error })
return undefined
}),
),
),
)
return [next, new Map(items).set(key, next)] as const
}),
)
})
const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: {
readonly directory: string
readonly providerID: ProviderID
readonly modelID: ModelID
}) {
return yield* yield* cachedLimit(input)
})
const sendUpdate = Effect.fn("ACPUsage.sendUpdate")(function* (input: {
readonly connection: UsageConnection
readonly sessionID: string
readonly directory: string
}) {
const messages = yield* messageLoader.messages({ sessionID: input.sessionID, directory: input.directory }).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to fetch messages for usage update", { error })
return undefined
}),
),
)
if (!messages) return
const message = latestAssistantMessage(messages)
if (!message) return
if (!message.providerID || !message.modelID) return
const size = yield* contextLimit({
directory: input.directory,
providerID: ProviderID.make(message.providerID),
modelID: ModelID.make(message.modelID),
})
if (!size) return
yield* Effect.promise(() =>
input.connection
.sessionUpdate({
sessionId: input.sessionID,
update: {
sessionUpdate: "usage_update",
used: message.tokens.input + message.tokens.cache.read,
size,
cost: { amount: totalSessionCost(messages), currency: "USD" },
},
})
.catch((error) => {
log.error("failed to send usage update", { error })
}),
)
})
return Service.of({
buildUsage,
latestAssistantMessage,
totalSessionCost,
contextLimit,
sendUpdate,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(contextLimitLoaderLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(InstanceStore.defaultLayer),
)
export * as UsageService from "./usage"