mirror of
https://github.com/aljazceru/opencode.git
synced 2026-02-11 11:04:50 +01:00
feat: Add ACP (Agent Client Protocol) support (#2947)
Co-authored-by: opencode-bot <devnull@opencode.local> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zed-industries/agent-client-protocol": "0.4.5",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"chokidar": "4.0.3",
|
||||
|
||||
164
packages/opencode/src/acp/README.md
Normal file
164
packages/opencode/src/acp/README.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# 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 `@zed-industries/agent-client-protocol`
|
||||
- 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
|
||||
```
|
||||
|
||||
### 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 `@zed-industries/agent-client-protocol` 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/zed-industries/agent-client-protocol/tree/main/typescript)
|
||||
- [Protocol Examples](https://github.com/zed-industries/agent-client-protocol/tree/main/typescript/examples)
|
||||
141
packages/opencode/src/acp/agent.ts
Normal file
141
packages/opencode/src/acp/agent.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AuthenticateRequest,
|
||||
AuthenticateResponse,
|
||||
CancelNotification,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
} from "@zed-industries/agent-client-protocol"
|
||||
import { Log } from "../util/log"
|
||||
import { ACPSessionManager } from "./session"
|
||||
import type { ACPConfig } from "./types"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { SessionPrompt } from "../session/prompt"
|
||||
import { Identifier } from "../id/id"
|
||||
|
||||
export class OpenCodeAgent implements Agent {
|
||||
private log = Log.create({ service: "acp-agent" })
|
||||
private sessionManager = new ACPSessionManager()
|
||||
private connection: AgentSideConnection
|
||||
private config: ACPConfig
|
||||
|
||||
constructor(connection: AgentSideConnection, config: ACPConfig = {}) {
|
||||
this.connection = connection
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
|
||||
this.log.info("initialize", { protocolVersion: params.protocolVersion })
|
||||
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: false,
|
||||
},
|
||||
_meta: {
|
||||
opencode: {
|
||||
version: await import("../installation").then((m) => m.Installation.VERSION),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate(params: AuthenticateRequest): Promise<void | AuthenticateResponse> {
|
||||
this.log.info("authenticate", { methodId: params.methodId })
|
||||
throw new Error("Authentication not yet implemented")
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
this.log.info("newSession", { cwd: params.cwd, mcpServers: params.mcpServers.length })
|
||||
|
||||
const session = await this.sessionManager.create(params.cwd, params.mcpServers)
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
_meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
this.log.info("loadSession", { sessionId: params.sessionId, cwd: params.cwd })
|
||||
|
||||
await this.sessionManager.load(params.sessionId, params.cwd, params.mcpServers)
|
||||
|
||||
return {
|
||||
_meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
this.log.info("prompt", {
|
||||
sessionId: params.sessionId,
|
||||
promptLength: params.prompt.length,
|
||||
})
|
||||
|
||||
const acpSession = this.sessionManager.get(params.sessionId)
|
||||
if (!acpSession) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`)
|
||||
}
|
||||
|
||||
const model = this.config.defaultModel || (await Provider.defaultModel())
|
||||
|
||||
const parts = params.prompt.map((content) => {
|
||||
if (content.type === "text") {
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: content.text,
|
||||
}
|
||||
}
|
||||
if (content.type === "resource") {
|
||||
const resource = content.resource
|
||||
let text = ""
|
||||
if ("text" in resource && typeof resource.text === "string") {
|
||||
text = resource.text
|
||||
}
|
||||
return {
|
||||
type: "text" as const,
|
||||
text,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(content),
|
||||
}
|
||||
})
|
||||
|
||||
await SessionPrompt.prompt({
|
||||
sessionID: acpSession.openCodeSessionId,
|
||||
messageID: Identifier.ascending("message"),
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
},
|
||||
parts,
|
||||
acpConnection: {
|
||||
connection: this.connection,
|
||||
sessionId: params.sessionId,
|
||||
},
|
||||
})
|
||||
|
||||
this.log.debug("prompt response completed")
|
||||
|
||||
// Streaming notifications are now handled during prompt execution
|
||||
// No need to send final text chunk here
|
||||
|
||||
return {
|
||||
stopReason: "end_turn",
|
||||
_meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(params: CancelNotification): Promise<void> {
|
||||
this.log.info("cancel", { sessionId: params.sessionId })
|
||||
}
|
||||
}
|
||||
85
packages/opencode/src/acp/client.ts
Normal file
85
packages/opencode/src/acp/client.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
Client,
|
||||
CreateTerminalRequest,
|
||||
CreateTerminalResponse,
|
||||
KillTerminalCommandRequest,
|
||||
KillTerminalResponse,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
ReleaseTerminalRequest,
|
||||
ReleaseTerminalResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionNotification,
|
||||
TerminalOutputRequest,
|
||||
TerminalOutputResponse,
|
||||
WaitForTerminalExitRequest,
|
||||
WaitForTerminalExitResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@zed-industries/agent-client-protocol"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export class ACPClient implements Client {
|
||||
private log = Log.create({ service: "acp-client" })
|
||||
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
this.log.debug("requestPermission", params)
|
||||
const firstOption = params.options[0]
|
||||
if (!firstOption) {
|
||||
return { outcome: { outcome: "cancelled" } }
|
||||
}
|
||||
return {
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: firstOption.optionId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
this.log.debug("sessionUpdate", { sessionId: params.sessionId })
|
||||
}
|
||||
|
||||
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
this.log.debug("writeTextFile", { path: params.path })
|
||||
await Bun.write(params.path, params.content)
|
||||
return { _meta: {} }
|
||||
}
|
||||
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
this.log.debug("readTextFile", { path: params.path })
|
||||
const file = Bun.file(params.path)
|
||||
const exists = await file.exists()
|
||||
if (!exists) {
|
||||
throw new Error(`File not found: ${params.path}`)
|
||||
}
|
||||
const content = await file.text()
|
||||
return { content, _meta: {} }
|
||||
}
|
||||
|
||||
async createTerminal(params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
|
||||
this.log.debug("createTerminal", params)
|
||||
throw new Error("Terminal support not yet implemented")
|
||||
}
|
||||
|
||||
async terminalOutput(params: TerminalOutputRequest): Promise<TerminalOutputResponse> {
|
||||
this.log.debug("terminalOutput", params)
|
||||
throw new Error("Terminal support not yet implemented")
|
||||
}
|
||||
|
||||
async releaseTerminal(params: ReleaseTerminalRequest): Promise<void | ReleaseTerminalResponse> {
|
||||
this.log.debug("releaseTerminal", params)
|
||||
throw new Error("Terminal support not yet implemented")
|
||||
}
|
||||
|
||||
async waitForTerminalExit(params: WaitForTerminalExitRequest): Promise<WaitForTerminalExitResponse> {
|
||||
this.log.debug("waitForTerminalExit", params)
|
||||
throw new Error("Terminal support not yet implemented")
|
||||
}
|
||||
|
||||
async killTerminal(params: KillTerminalCommandRequest): Promise<void | KillTerminalResponse> {
|
||||
this.log.debug("killTerminal", params)
|
||||
throw new Error("Terminal support not yet implemented")
|
||||
}
|
||||
}
|
||||
53
packages/opencode/src/acp/server.ts
Normal file
53
packages/opencode/src/acp/server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { AgentSideConnection, ndJsonStream } from "@zed-industries/agent-client-protocol"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
import { OpenCodeAgent } from "./agent"
|
||||
|
||||
export namespace ACPServer {
|
||||
const log = Log.create({ service: "acp-server" })
|
||||
|
||||
export async function start() {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
fn: async () => {
|
||||
log.info("starting ACP server", { cwd: process.cwd() })
|
||||
|
||||
const stdout = new WritableStream({
|
||||
write(chunk) {
|
||||
process.stdout.write(chunk)
|
||||
},
|
||||
})
|
||||
|
||||
const stdin = new ReadableStream({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
process.stdin.on("end", () => {
|
||||
controller.close()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const stream = ndJsonStream(stdout, stdin)
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
return new OpenCodeAgent(conn)
|
||||
}, stream)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
process.on("SIGTERM", () => {
|
||||
log.info("received SIGTERM")
|
||||
resolve()
|
||||
})
|
||||
process.on("SIGINT", () => {
|
||||
log.info("received SIGINT")
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
log.info("ACP server stopped")
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
60
packages/opencode/src/acp/session.ts
Normal file
60
packages/opencode/src/acp/session.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { McpServer } from "@zed-industries/agent-client-protocol"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Session } from "../session"
|
||||
import type { ACPSessionState } from "./types"
|
||||
|
||||
export class ACPSessionManager {
|
||||
private sessions = new Map<string, ACPSessionState>()
|
||||
|
||||
async create(cwd: string, mcpServers: McpServer[]): Promise<ACPSessionState> {
|
||||
const sessionId = `acp_${Identifier.ascending("session")}`
|
||||
const openCodeSession = await Session.create({ title: `ACP Session ${sessionId}` })
|
||||
|
||||
const state: ACPSessionState = {
|
||||
id: sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
openCodeSessionId: openCodeSession.id,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, state)
|
||||
return state
|
||||
}
|
||||
|
||||
get(sessionId: string): ACPSessionState | undefined {
|
||||
return this.sessions.get(sessionId)
|
||||
}
|
||||
|
||||
async remove(sessionId: string): Promise<void> {
|
||||
const state = this.sessions.get(sessionId)
|
||||
if (!state) return
|
||||
|
||||
await Session.remove(state.openCodeSessionId).catch(() => {})
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
|
||||
has(sessionId: string): boolean {
|
||||
return this.sessions.has(sessionId)
|
||||
}
|
||||
|
||||
async load(sessionId: string, cwd: string, mcpServers: McpServer[]): Promise<ACPSessionState> {
|
||||
const existing = this.sessions.get(sessionId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const openCodeSession = await Session.create({ title: `ACP Session ${sessionId} (loaded)` })
|
||||
|
||||
const state: ACPSessionState = {
|
||||
id: sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
openCodeSessionId: openCodeSession.id,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, state)
|
||||
return state
|
||||
}
|
||||
}
|
||||
16
packages/opencode/src/acp/types.ts
Normal file
16
packages/opencode/src/acp/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { McpServer } from "@zed-industries/agent-client-protocol"
|
||||
|
||||
export interface ACPSessionState {
|
||||
id: string
|
||||
cwd: string
|
||||
mcpServers: McpServer[]
|
||||
openCodeSessionId: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface ACPConfig {
|
||||
defaultModel?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
}
|
||||
21
packages/opencode/src/cli/cmd/acp.ts
Normal file
21
packages/opencode/src/cli/cmd/acp.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CommandModule } from "yargs"
|
||||
import { ACPServer } from "../../acp/server"
|
||||
|
||||
export const AcpCommand: CommandModule = {
|
||||
command: "acp",
|
||||
describe: "Start ACP (Agent Client Protocol) server",
|
||||
builder: (yargs) => {
|
||||
return yargs.option("cwd", {
|
||||
describe: "working directory",
|
||||
type: "string",
|
||||
default: process.cwd(),
|
||||
})
|
||||
},
|
||||
handler: async (opts) => {
|
||||
if (opts["cwd"] && typeof opts["cwd"] === "string") {
|
||||
process.chdir(opts["cwd"])
|
||||
}
|
||||
|
||||
await ACPServer.start()
|
||||
},
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { McpCommand } from "./cli/cmd/mcp"
|
||||
import { GithubCommand } from "./cli/cmd/github"
|
||||
import { ExportCommand } from "./cli/cmd/export"
|
||||
import { AttachCommand } from "./cli/cmd/attach"
|
||||
import { AcpCommand } from "./cli/cmd/acp"
|
||||
|
||||
const cancel = new AbortController()
|
||||
|
||||
@@ -67,6 +68,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
})
|
||||
})
|
||||
.usage("\n" + UI.logo())
|
||||
.command(AcpCommand)
|
||||
.command(McpCommand)
|
||||
.command(TuiCommand)
|
||||
.command(AttachCommand)
|
||||
|
||||
@@ -95,6 +95,16 @@ export namespace SessionPrompt {
|
||||
agent: z.string().optional(),
|
||||
system: z.string().optional(),
|
||||
tools: z.record(z.string(), z.boolean()).optional(),
|
||||
/**
|
||||
* ACP (Agent Client Protocol) connection details for streaming responses.
|
||||
* When provided, enables real-time streaming and tool execution visibility.
|
||||
*/
|
||||
acpConnection: z
|
||||
.object({
|
||||
connection: z.any(), // AgentSideConnection - using any to avoid circular deps
|
||||
sessionId: z.string(), // ACP session ID (different from opencode sessionID)
|
||||
})
|
||||
.optional(),
|
||||
parts: z.array(
|
||||
z.discriminatedUnion("type", [
|
||||
MessageV2.TextPart.omit({
|
||||
@@ -173,6 +183,7 @@ export namespace SessionPrompt {
|
||||
agent: agent.name,
|
||||
system,
|
||||
abort: abort.signal,
|
||||
acpConnection: input.acpConnection,
|
||||
})
|
||||
|
||||
const tools = await resolveTools({
|
||||
@@ -820,6 +831,60 @@ export namespace SessionPrompt {
|
||||
return input.messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tool names to ACP tool kinds for consistent categorization.
|
||||
* - read: Tools that read data (read, glob, grep, list, webfetch, docs)
|
||||
* - edit: Tools that modify state (edit, write, bash)
|
||||
* - other: All other tools (MCP tools, task, todowrite, etc.)
|
||||
*/
|
||||
function determineToolKind(toolName: string): "read" | "edit" | "other" {
|
||||
const readTools = [
|
||||
"read",
|
||||
"glob",
|
||||
"grep",
|
||||
"list",
|
||||
"webfetch",
|
||||
"context7_resolve_library_id",
|
||||
"context7_get_library_docs",
|
||||
]
|
||||
const editTools = ["edit", "write", "bash"]
|
||||
|
||||
if (readTools.includes(toolName.toLowerCase())) return "read"
|
||||
if (editTools.includes(toolName.toLowerCase())) return "edit"
|
||||
return "other"
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts file/directory locations from tool inputs for ACP notifications.
|
||||
* Returns array of {path} objects that ACP clients can use for navigation.
|
||||
*
|
||||
* Examples:
|
||||
* - read({filePath: "/foo/bar.ts"}) -> [{path: "/foo/bar.ts"}]
|
||||
* - glob({pattern: "*.ts", path: "/src"}) -> [{path: "/src"}]
|
||||
* - bash({command: "ls"}) -> [] (no file references)
|
||||
*/
|
||||
function extractLocations(toolName: string, input: Record<string, any>): { path: string }[] {
|
||||
try {
|
||||
switch (toolName.toLowerCase()) {
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return input["filePath"] ? [{ path: input["filePath"] }] : []
|
||||
case "glob":
|
||||
case "grep":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
case "bash":
|
||||
return []
|
||||
case "list":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export type Processor = Awaited<ReturnType<typeof createProcessor>>
|
||||
async function createProcessor(input: {
|
||||
sessionID: string
|
||||
@@ -828,6 +893,10 @@ export namespace SessionPrompt {
|
||||
system: string[]
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
acpConnection?: {
|
||||
connection: any
|
||||
sessionId: string
|
||||
}
|
||||
}) {
|
||||
const toolcalls: Record<string, MessageV2.ToolPart> = {}
|
||||
let snapshot: string | undefined
|
||||
@@ -955,6 +1024,26 @@ export namespace SessionPrompt {
|
||||
},
|
||||
})
|
||||
toolcalls[value.id] = part as MessageV2.ToolPart
|
||||
|
||||
// Notify ACP client of pending tool call
|
||||
if (input.acpConnection) {
|
||||
await input.acpConnection.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.acpConnection.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: value.id,
|
||||
title: value.toolName,
|
||||
kind: determineToolKind(value.toolName),
|
||||
status: "pending",
|
||||
locations: [], // Will be populated when we have input
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
log.error("failed to send tool pending to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case "tool-input-delta":
|
||||
@@ -979,6 +1068,24 @@ export namespace SessionPrompt {
|
||||
metadata: value.providerMetadata,
|
||||
})
|
||||
toolcalls[value.toolCallId] = part as MessageV2.ToolPart
|
||||
|
||||
// Notify ACP client that tool is running
|
||||
if (input.acpConnection) {
|
||||
await input.acpConnection.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.acpConnection.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: value.toolCallId,
|
||||
status: "in_progress",
|
||||
locations: extractLocations(value.toolName, value.input),
|
||||
rawInput: value.input,
|
||||
},
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
log.error("failed to send tool in_progress to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -1000,6 +1107,33 @@ export namespace SessionPrompt {
|
||||
attachments: value.output.attachments,
|
||||
},
|
||||
})
|
||||
|
||||
// Notify ACP client that tool completed
|
||||
if (input.acpConnection) {
|
||||
await input.acpConnection.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.acpConnection.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: value.toolCallId,
|
||||
status: "completed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: value.output.output,
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: value.output,
|
||||
},
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
log.error("failed to send tool completed to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
|
||||
delete toolcalls[value.toolCallId]
|
||||
}
|
||||
break
|
||||
@@ -1021,6 +1155,35 @@ export namespace SessionPrompt {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Notify ACP client of tool error
|
||||
if (input.acpConnection) {
|
||||
await input.acpConnection.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.acpConnection.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: value.toolCallId,
|
||||
status: "failed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Error: ${(value.error as any).toString()}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
error: (value.error as any).toString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
log.error("failed to send tool error to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
|
||||
if (value.error instanceof Permission.RejectedError) {
|
||||
blocked = true
|
||||
}
|
||||
@@ -1093,6 +1256,25 @@ export namespace SessionPrompt {
|
||||
currentText.text += value.text
|
||||
if (value.providerMetadata) currentText.metadata = value.providerMetadata
|
||||
if (currentText.text) await Session.updatePart(currentText)
|
||||
|
||||
// Send streaming chunk to ACP client
|
||||
if (input.acpConnection && value.text) {
|
||||
await input.acpConnection.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.acpConnection.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: value.text,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
log.error("failed to send text delta to ACP", { error: err })
|
||||
// Don't fail the whole request if ACP notification fails
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ import { FileTime } from "../file/time"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { trimDiff } from "./edit"
|
||||
|
||||
export const WriteTool = Tool.define("write", {
|
||||
description: DESCRIPTION,
|
||||
@@ -29,13 +27,6 @@ export const WriteTool = Tool.define("write", {
|
||||
const exists = await file.exists()
|
||||
if (exists) await FileTime.assert(ctx.sessionID, filepath)
|
||||
|
||||
let oldContent = ""
|
||||
let diff = ""
|
||||
|
||||
if (exists) {
|
||||
oldContent = await file.text()
|
||||
}
|
||||
|
||||
const agent = await Agent.get(ctx.agent)
|
||||
if (agent.permission.edit === "ask")
|
||||
await Permission.ask({
|
||||
@@ -57,9 +48,6 @@ export const WriteTool = Tool.define("write", {
|
||||
})
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
// Generate diff for the write operation
|
||||
diff = trimDiff(createTwoFilesPatch(filepath, filepath, oldContent, params.content))
|
||||
|
||||
let output = ""
|
||||
await LSP.touchFile(filepath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
|
||||
109
packages/opencode/test/acp.test.ts
Normal file
109
packages/opencode/test/acp.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { spawn } from "child_process"
|
||||
|
||||
describe("ACP Server", () => {
|
||||
test("initialize and shutdown", async () => {
|
||||
const proc = spawn("bun", ["run", "--conditions=development", "src/index.ts", "acp"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, OPENCODE: "1" },
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
let initResponse: any = null
|
||||
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
const lines = decoder.decode(chunk).split("\n")
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(trimmed)
|
||||
if (msg.id === 1) initResponse = msg
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
|
||||
proc.stdin.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: { protocolVersion: 1 },
|
||||
}) + "\n",
|
||||
),
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
expect(initResponse).toBeTruthy()
|
||||
expect(initResponse.result.protocolVersion).toBe(1)
|
||||
expect(initResponse.result.agentCapabilities).toBeTruthy()
|
||||
|
||||
proc.kill()
|
||||
}, 10000)
|
||||
|
||||
test("create session", async () => {
|
||||
const proc = spawn("bun", ["run", "--conditions=development", "src/index.ts", "acp"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, OPENCODE: "1" },
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
let sessionResponse: any = null
|
||||
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
const lines = decoder.decode(chunk).split("\n")
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(trimmed)
|
||||
if (msg.id === 2) sessionResponse = msg
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
|
||||
proc.stdin.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: { protocolVersion: 1 },
|
||||
}) + "\n",
|
||||
),
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
proc.stdin.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "session/new",
|
||||
params: {
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
},
|
||||
}) + "\n",
|
||||
),
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
expect(sessionResponse).toBeTruthy()
|
||||
expect(sessionResponse.result.sessionId).toBeTruthy()
|
||||
|
||||
proc.kill()
|
||||
}, 10000)
|
||||
})
|
||||
Reference in New Issue
Block a user