diff options
| author | Joe Schmitt <[email protected]> | 2025-10-20 17:55:22 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-10-20 16:55:22 -0500 |
| commit | f3f21194ae0ca15876507dfb71a55a28e0fdc7c4 (patch) | |
| tree | a0ee40a721aec63aa1b965888a4518e433331259 /packages | |
| parent | 835fa9fb811288f8ce0ff24f765446986ba47682 (diff) | |
| download | opencode-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.json | 1 | ||||
| -rw-r--r-- | packages/opencode/src/acp/README.md | 164 | ||||
| -rw-r--r-- | packages/opencode/src/acp/agent.ts | 141 | ||||
| -rw-r--r-- | packages/opencode/src/acp/client.ts | 85 | ||||
| -rw-r--r-- | packages/opencode/src/acp/server.ts | 53 | ||||
| -rw-r--r-- | packages/opencode/src/acp/session.ts | 60 | ||||
| -rw-r--r-- | packages/opencode/src/acp/types.ts | 16 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/acp.ts | 21 | ||||
| -rw-r--r-- | packages/opencode/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/opencode/src/session/prompt.ts | 182 | ||||
| -rw-r--r-- | packages/opencode/src/tool/write.ts | 12 | ||||
| -rw-r--r-- | packages/opencode/test/acp.test.ts | 109 |
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) +}) |
