summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorJoe Schmitt <[email protected]>2025-10-20 17:55:22 -0400
committerGitHub <[email protected]>2025-10-20 16:55:22 -0500
commitf3f21194ae0ca15876507dfb71a55a28e0fdc7c4 (patch)
treea0ee40a721aec63aa1b965888a4518e433331259 /packages
parent835fa9fb811288f8ce0ff24f765446986ba47682 (diff)
downloadopencode-f3f21194ae0ca15876507dfb71a55a28e0fdc7c4.tar.gz
opencode-f3f21194ae0ca15876507dfb71a55a28e0fdc7c4.zip
feat: Add ACP (Agent Client Protocol) support (#2947)
Co-authored-by: opencode-bot <[email protected]> Co-authored-by: Dax Raad <[email protected]> Co-authored-by: GitHub Action <[email protected]>
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/package.json1
-rw-r--r--packages/opencode/src/acp/README.md164
-rw-r--r--packages/opencode/src/acp/agent.ts141
-rw-r--r--packages/opencode/src/acp/client.ts85
-rw-r--r--packages/opencode/src/acp/server.ts53
-rw-r--r--packages/opencode/src/acp/session.ts60
-rw-r--r--packages/opencode/src/acp/types.ts16
-rw-r--r--packages/opencode/src/cli/cmd/acp.ts21
-rw-r--r--packages/opencode/src/index.ts2
-rw-r--r--packages/opencode/src/session/prompt.ts182
-rw-r--r--packages/opencode/src/tool/write.ts12
-rw-r--r--packages/opencode/test/acp.test.ts109
12 files changed, 834 insertions, 12 deletions
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index d45804e1c..3b998ab36 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -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",
diff --git a/packages/opencode/src/acp/README.md b/packages/opencode/src/acp/README.md
new file mode 100644
index 000000000..5e51af13c
--- /dev/null
+++ b/packages/opencode/src/acp/README.md
@@ -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)
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
new file mode 100644
index 000000000..d0981b056
--- /dev/null
+++ b/packages/opencode/src/acp/agent.ts
@@ -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 })
+ }
+}
diff --git a/packages/opencode/src/acp/client.ts b/packages/opencode/src/acp/client.ts
new file mode 100644
index 000000000..50d599916
--- /dev/null
+++ b/packages/opencode/src/acp/client.ts
@@ -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")
+ }
+}
diff --git a/packages/opencode/src/acp/server.ts b/packages/opencode/src/acp/server.ts
new file mode 100644
index 000000000..667a09873
--- /dev/null
+++ b/packages/opencode/src/acp/server.ts
@@ -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")
+ },
+ })
+ }
+}
diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts
new file mode 100644
index 000000000..01d079bcf
--- /dev/null
+++ b/packages/opencode/src/acp/session.ts
@@ -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
+ }
+}
diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts
new file mode 100644
index 000000000..f10066a8b
--- /dev/null
+++ b/packages/opencode/src/acp/types.ts
@@ -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
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts
new file mode 100644
index 000000000..4ae5dc839
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/acp.ts
@@ -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()
+ },
+}
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 342034eed..525944c02 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -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)
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 234f47b69..e2500c245 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -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
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index 64f9feb20..aa79c9bfb 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -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()
diff --git a/packages/opencode/test/acp.test.ts b/packages/opencode/test/acp.test.ts
new file mode 100644
index 000000000..b4ed5d2d1
--- /dev/null
+++ b/packages/opencode/test/acp.test.ts
@@ -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)
+})