diff --git a/docs/cline-cli/acp.mdx b/docs/cline-cli/acp.mdx new file mode 100644 index 0000000000..9902c51387 --- /dev/null +++ b/docs/cline-cli/acp.mdx @@ -0,0 +1,75 @@ + +--- +title: "ACP Integration (Zed + Custom Clients)" +description: "Run Cline as an Agent Client Protocol (ACP) server and connect it to Zed or any ACP-compatible client." +--- + +## Overview + +Cline ships an ACP-compatible stdio server so you can run it inside editors like Zed or any client that speaks the Agent Client Protocol. + +The ACP entrypoint lives at `dist-standalone/cline-acp.js` after building the standalone bundle. + +## Build the ACP server + +From the Cline repo root: + +```bash +npm install +npm run compile-standalone +``` + +The ACP server will be available at: + +```text +dist-standalone/cline-acp.js +``` + +## Run manually + +```bash +node dist-standalone/cline-acp.js --config ~/.cline +``` + +Notes: + +- `--config` sets the Cline data directory (same as `CLINE_DIR`). If omitted, Cline defaults to `~/.cline`. +- Logs go to stderr to keep ACP JSON-RPC traffic clean on stdout. + +## Connect to Zed + +Add a custom ACP agent to your Zed `settings.json` (open with `zed: open settings`): + +```json [settings] +{ + "agent_servers": { + "Cline (ACP)": { + "type": "custom", + "command": "node", + "args": [ + "/absolute/path/to/cline/dist-standalone/cline-acp.js", + "--config", + "/absolute/path/to/.cline" + ], + "env": {} + } + } +} +``` + +Then open the Agent Panel in Zed and start a new thread for **Cline (ACP)**. + +Zed will: + +- Provide the workspace `cwd` for each session. +- Forward MCP servers you configured in Zed to Cline over ACP. + +## Limitations + +- Cline currently supports one ACP session at a time; starting a new session closes the previous one. +- Some Cline UI affordances (like special buttons for "start new task") are represented as standard prompts in ACP clients. + +## Troubleshooting + +- Check Zed’s ACP logs (`Help` → `Open ACP Logs`) if the agent fails to start. +- Ensure the path to `cline-acp.js` is absolute and your Node binary is on `PATH`. \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json index b0dd865314..691efa4c26 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -88,6 +88,7 @@ "cline-cli/overview", "cline-cli/installation", "cline-cli/three-core-flows", + "cline-cli/acp", { "group": "CLI Samples", "pages": [ diff --git a/esbuild.mjs b/esbuild.mjs index 49103296af..11858be6d3 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -196,7 +196,10 @@ const baseConfig = { // Extension-specific configuration const extensionConfig = { ...baseConfig, - entryPoints: ["src/extension.ts"], + entryPoints: { + "cline-core": "src/standalone/cline-core.ts", + "cline-acp": "src/standalone/cline-acp.ts", + }, outfile: `${destDir}/extension.js`, external: ["vscode"], } diff --git a/package-lock.json b/package-lock.json index e760f15d33..db558e9dd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.49.0", "license": "Apache-2.0", "dependencies": { + "@agentclientprotocol/sdk": "^0.11.0", "@anthropic-ai/sdk": "^0.37.0", "@anthropic-ai/vertex-sdk": "^0.6.4", "@aws-sdk/client-bedrock-runtime": "^3.922.0", @@ -152,6 +153,15 @@ "vscode": "^1.84.0" } }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.11.0.tgz", + "integrity": "sha512-hngnMwQ13DCC7oEr0BUnrx+vTDFf/ToCLhF0YcCMWRs+v4X60rKQyAENsx0PdbQF21jC1VjMFkh2+vwNBLh6fQ==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.37.0", "license": "MIT", @@ -4899,314 +4909,6 @@ "node": ">=18" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", - "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", - "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", - "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", - "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", - "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", - "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", - "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", - "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", - "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", - "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", - "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", - "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", - "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", - "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", - "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", - "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", - "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", - "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", - "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", - "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", - "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", - "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, "node_modules/@sap-ai-sdk/ai-api": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.1.0.tgz", diff --git a/package.json b/package.json index a92eaae654..b004218a0c 100644 --- a/package.json +++ b/package.json @@ -453,6 +453,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.11.0", "@anthropic-ai/sdk": "^0.37.0", "@anthropic-ai/vertex-sdk": "^0.6.4", "@aws-sdk/client-bedrock-runtime": "^3.922.0", diff --git a/src/standalone/acp/acp-agent.ts b/src/standalone/acp/acp-agent.ts new file mode 100644 index 0000000000..8bd1c7b684 --- /dev/null +++ b/src/standalone/acp/acp-agent.ts @@ -0,0 +1,554 @@ +import { randomUUID } from "node:crypto" +import * as path from "node:path" +import { fileURLToPath } from "node:url" +import { + Agent, + AgentSideConnection, + CancelNotification, + type ContentBlock, + InitializeRequest, + InitializeResponse, + NewSessionRequest, + NewSessionResponse, + ndJsonStream, + PromptRequest, + PromptResponse, + RequestError, + type RequestPermissionResponse, + type SessionNotification, + SetSessionModeRequest, + SetSessionModeResponse, +} from "@agentclientprotocol/sdk" +import type { ExtensionContext } from "vscode" +import { initialize, tearDown } from "@/common" +import { Controller } from "@/core/controller" +import { getRequestRegistry } from "@/core/controller/grpc-handler" +import { subscribeToState } from "@/core/controller/state/subscribeToState" +import { subscribeToPartialMessage } from "@/core/controller/ui/subscribeToPartialMessage" +import { AuthHandler } from "@/hosts/external/AuthHandler" +import { ExternalCommentReviewController } from "@/hosts/external/ExternalCommentReviewController" +import { ExternalDiffViewProvider } from "@/hosts/external/ExternalDiffviewProvider" +import { ExternalWebviewProvider } from "@/hosts/external/ExternalWebviewProvider" +import { ExternalHostBridgeClientManager } from "@/hosts/external/host-bridge-client-manager" +import { HostProvider } from "@/hosts/host-provider" +import { StandaloneTerminalManager } from "@/integrations/terminal" +import type { McpServerConfig } from "@/services/mcp/types" +import type { ClineMessage, ExtensionState } from "@/shared/ExtensionMessage" +import { EmptyRequest } from "@/shared/proto/cline/common" +import { State } from "@/shared/proto/cline/state" +import type { ClineMessage as ProtoClineMessage } from "@/shared/proto/cline/ui" +import { convertProtoToClineMessage } from "@/shared/proto-conversions/cline-message" +import { waitForHostBridgeReady } from "@/standalone/hostbridge-client" +import { initializeContext } from "@/standalone/vscode-context" +import packageJson from "../../../package.json" +import { + buildModeState, + buildNotificationsForMessage, + buildNotificationsForPartialMessage, + buildPermissionToolCall, + buildToolCallDetailsFromMessage, + createAcpConversionState, + isPermissionAskType, + resolveClineModeId, + shouldSkipStateMessage, +} from "./convert" +import { nodeToWebReadable, nodeToWebWritable } from "./stdio" + +const PERMISSION_OPTIONS = [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + { optionId: "allow_always", name: "Always Allow", kind: "allow_always" }, +] + +type ClineAcpRuntime = { + controller: Controller + dispose: () => Promise +} + +type SessionSubscriptions = { + stateRequestId: string + partialRequestId: string +} + +type ClineAcpSession = { + sessionId: string + controller: Controller + conversionState: ReturnType + lastMessageCount: number + lastModeId: "plan" | "act" + awaitingUserInput: boolean + pendingAsk?: ClineMessage + permissionRequests: Set + promptInFlight: boolean + cancelled: boolean + subscriptions: SessionSubscriptions +} + +export class ClineAcpAgent implements Agent { + private runtime?: ClineAcpRuntime + private runtimePromise?: Promise + private sessions = new Map() + + constructor(private client: AgentSideConnection) {} + + async initialize(request: InitializeRequest): Promise { + return { + protocolVersion: 1, + agentCapabilities: { + promptCapabilities: { + image: true, + embeddedContext: true, + audio: false, + }, + mcpCapabilities: { + http: true, + sse: true, + }, + sessionCapabilities: {}, + }, + agentInfo: { + name: packageJson.name, + title: "Cline", + version: packageJson.version, + }, + authMethods: [], + _meta: request._meta ?? null, + } + } + + async newSession(params: NewSessionRequest): Promise { + const runtime = await this.ensureRuntime(params.cwd) + + if (this.sessions.size > 0) { + for (const sessionId of this.sessions.keys()) { + await this.closeSession(sessionId) + } + } + + await this.configureMcpServers(runtime.controller, params.mcpServers) + + const sessionId = randomUUID() + const initialMode = resolveClineModeId(runtime.controller.stateManager.getGlobalSettingsKey("mode")) + const subscriptions = await this.attachSubscriptions(runtime.controller, sessionId) + + this.sessions.set(sessionId, { + sessionId, + controller: runtime.controller, + conversionState: createAcpConversionState(), + lastMessageCount: 0, + lastModeId: initialMode, + awaitingUserInput: false, + permissionRequests: new Set(), + promptInFlight: false, + cancelled: false, + subscriptions, + }) + + await this.warnIfCwdMismatch(params.cwd) + + return { + sessionId, + modes: buildModeState(initialMode), + } + } + + async prompt(params: PromptRequest): Promise { + const session = this.getSession(params.sessionId) + if (session.promptInFlight) { + throw new Error("Prompt already in progress") + } + + session.promptInFlight = true + session.cancelled = false + + try { + const { text, images, files } = extractPromptContent(params.prompt) + const task = session.controller.task + const isStreaming = task?.taskState.isStreaming || task?.taskState.isWaitingForFirstChunk + + if (session.pendingAsk && task) { + session.awaitingUserInput = false + session.pendingAsk = undefined + await task.handleWebviewAskResponse("messageResponse", text, images, files) + } else if (!task || !isStreaming) { + await session.controller.initTask(text, images, files) + } else { + throw new Error("Task is busy; wait for the current turn to finish") + } + + const stopReason = await this.waitForTurnCompletion(session) + return { stopReason } + } finally { + session.promptInFlight = false + } + } + + async cancel(params: CancelNotification): Promise { + const session = this.getSession(params.sessionId) + session.cancelled = true + await session.controller.cancelTask() + } + + async setSessionMode(params: SetSessionModeRequest): Promise { + const session = this.getSession(params.sessionId) + const modeId = params.modeId === "act" ? "act" : "plan" + await session.controller.togglePlanActMode(modeId) + session.lastModeId = modeId + await this.client.sessionUpdate({ + sessionId: session.sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId: modeId, + }, + }) + return {} + } + + private async ensureRuntime(cwd: string): Promise { + if (this.runtime) { + return this.runtime + } + if (!this.runtimePromise) { + this.runtimePromise = this.createRuntime(cwd) + } + this.runtime = await this.runtimePromise + return this.runtime + } + + private async createRuntime(cwd: string): Promise { + process.chdir(path.dirname(fileURLToPath(import.meta.url))) + await waitForHostBridgeReady() + + const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeContext(undefined) + this.setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR) + const webviewProvider = await initialize(extensionContext) + AuthHandler.getInstance().setEnabled(true) + + return { + controller: webviewProvider.controller, + dispose: async () => { + await tearDown() + }, + } + } + + private setupHostProvider(extensionContext: ExtensionContext, extensionDir: string, dataDir: string) { + const createWebview = () => new ExternalWebviewProvider(extensionContext) + const createDiffView = () => new ExternalDiffViewProvider() + const createCommentReview = () => new ExternalCommentReviewController() + const createTerminalManager = () => new StandaloneTerminalManager() + const getCallbackUrl = async () => AuthHandler.getInstance().getCallbackUrl() + const getBinaryLocation = async (name: string) => path.join(process.cwd(), name) + + HostProvider.initialize( + createWebview, + createDiffView, + createCommentReview, + createTerminalManager, + new ExternalHostBridgeClientManager(), + (...args: unknown[]) => console.error(...args), + getCallbackUrl, + getBinaryLocation, + extensionDir, + dataDir, + ) + } + + private async configureMcpServers(controller: Controller, servers: NewSessionRequest["mcpServers"]) { + if (!servers?.length) { + return + } + const serverConfigs: Record = {} + for (const server of servers) { + if ("type" in server && server.type && server.type !== "stdio") { + const transportType = server.type === "http" ? "streamableHttp" : "sse" + serverConfigs[server.name] = { + type: transportType, + url: server.url, + headers: server.headers ? Object.fromEntries(server.headers.map((h) => [h.name, h.value])) : undefined, + } + } else { + serverConfigs[server.name] = { + type: "stdio", + command: server.command, + args: server.args, + env: server.env ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) : undefined, + } + } + } + + await controller.mcpHub.updateServerConnectionsRPC(serverConfigs) + } + + private async warnIfCwdMismatch(cwd: string) { + try { + const workspacePaths = await HostProvider.workspace.getWorkspacePaths({}) + const primary = workspacePaths.paths[0] + if (primary && path.resolve(primary) !== path.resolve(cwd)) { + console.error(`[cline-acp] Warning: ACP session cwd (${cwd}) does not match host bridge workspace (${primary}).`) + } + } catch (error) { + console.error("[cline-acp] Failed to check workspace paths:", error) + } + } + + private async attachSubscriptions(controller: Controller, sessionId: string): Promise { + const stateRequestId = `cline-acp-state-${sessionId}` + const partialRequestId = `cline-acp-partial-${sessionId}` + + await subscribeToState( + controller, + EmptyRequest.create(), + async (state: State) => { + await this.handleStateUpdate(sessionId, state) + }, + stateRequestId, + ) + + await subscribeToPartialMessage( + controller, + EmptyRequest.create(), + async (message) => { + await this.handlePartialMessage(sessionId, message) + }, + partialRequestId, + ) + + return { stateRequestId, partialRequestId } + } + + private async handleStateUpdate(sessionId: string, state: State) { + const session = this.sessions.get(sessionId) + if (!session || !state.stateJson) { + return + } + + let parsed: ExtensionState + try { + parsed = JSON.parse(state.stateJson) as ExtensionState + } catch (error) { + console.error("[cline-acp] Failed to parse state update:", error) + return + } + + const modeId = resolveClineModeId(parsed.mode) + if (modeId !== session.lastModeId) { + session.lastModeId = modeId + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId: modeId, + }, + }) + } + + const messages = parsed.clineMessages ?? [] + if (messages.length <= session.lastMessageCount) { + return + } + + const newMessages = messages.slice(session.lastMessageCount) + session.lastMessageCount = messages.length + + for (const message of newMessages) { + if (shouldSkipStateMessage(message, session.conversionState)) { + continue + } + await this.handleAskMessage(session, message) + await this.sendNotifications(sessionId, buildNotificationsForMessage(message, sessionId, session.conversionState)) + } + } + + private async handlePartialMessage(sessionId: string, message: ProtoClineMessage) { + const session = this.sessions.get(sessionId) + if (!session) { + return + } + const clineMessage = convertProtoToClineMessage(message) + const notifications = buildNotificationsForPartialMessage( + clineMessage, + sessionId, + session.conversionState, + message.partial === false, + ) + await this.sendNotifications(sessionId, notifications) + } + + private async handleAskMessage(session: ClineAcpSession, message: ClineMessage) { + if (message.type !== "ask") { + return + } + if (isPermissionAskType(message.ask)) { + await this.handlePermissionRequest(session, message) + return + } + session.awaitingUserInput = true + session.pendingAsk = message + } + + private async handlePermissionRequest(session: ClineAcpSession, message: ClineMessage) { + if (session.permissionRequests.has(message.ts)) { + return + } + session.permissionRequests.add(message.ts) + + const details = + buildToolCallDetailsFromMessage(message, session.conversionState, { + toolCallPrefix: "cline-permission", + fallbackTitle: "Permission required", + }) ?? + buildToolCallDetailsFromMessage( + { ...message, type: "say", say: "command", text: message.text }, + session.conversionState, + { toolCallPrefix: "cline-permission", fallbackTitle: "Permission required" }, + ) + + if (!details) { + return + } + + let response: RequestPermissionResponse + try { + response = await this.client.requestPermission({ + sessionId: session.sessionId, + toolCall: buildPermissionToolCall(details), + options: PERMISSION_OPTIONS, + }) + } catch (error) { + console.error("[cline-acp] Permission request failed:", error) + return + } + + if (response.outcome?.outcome === "cancelled" || session.cancelled) { + session.cancelled = true + await session.controller.cancelTask() + return + } + + const approved = + response.outcome?.outcome === "selected" && + (response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always") + + await session.controller.task?.handleWebviewAskResponse(approved ? "yesButtonClicked" : "noButtonClicked") + } + + private async waitForTurnCompletion(session: ClineAcpSession): Promise { + while (true) { + if (session.cancelled) { + return "cancelled" + } + + const task = session.controller.task + if (!task) { + return "end_turn" + } + + if (session.awaitingUserInput) { + return "end_turn" + } + + if (!task.taskState.isStreaming && !task.taskState.isWaitingForFirstChunk) { + return "end_turn" + } + + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + + private async sendNotifications(sessionId: string, notifications: SessionNotification[]) { + for (const notification of notifications) { + try { + await this.client.sessionUpdate(notification) + } catch (error) { + console.error(`[cline-acp] Failed to send session update for ${sessionId}:`, error) + } + } + } + + private async closeSession(sessionId: string) { + const session = this.sessions.get(sessionId) + if (!session) { + return + } + getRequestRegistry().cancelRequest(session.subscriptions.stateRequestId) + getRequestRegistry().cancelRequest(session.subscriptions.partialRequestId) + await session.controller.clearTask() + this.sessions.delete(sessionId) + } + + private getSession(sessionId: string): ClineAcpSession { + const session = this.sessions.get(sessionId) + if (!session) { + throw RequestError.invalidParams("Session not found") + } + return session + } +} + +export function runAcp() { + const input = nodeToWebWritable(process.stdout) + const output = nodeToWebReadable(process.stdin) + const stream = ndJsonStream(input, output) + new AgentSideConnection((client) => new ClineAcpAgent(client), stream) +} + +function extractPromptContent(blocks: ContentBlock[]): { text: string; images: string[]; files: string[] } { + const textParts: string[] = [] + const images: string[] = [] + const files: string[] = [] + const embeddedResources: string[] = [] + + for (const block of blocks ?? []) { + switch (block.type) { + case "text": + textParts.push(block.text) + break + case "image": + if (block.data && block.mimeType) { + images.push(`data:${block.mimeType};base64,${block.data}`) + } + break + case "resource": + if ("text" in block.resource && block.resource.text) { + embeddedResources.push(`\n${block.resource.text}\n`) + } + break + case "resource_link": { + const filePath = resolveFilePath(block.uri) + if (filePath) { + files.push(filePath) + } else { + textParts.push(`Resource: ${block.uri}`) + } + break + } + default: + break + } + } + + if (embeddedResources.length > 0) { + textParts.push(`Embedded resources:\n\n${embeddedResources.join("\n\n")}`) + } + + return { + text: textParts.join("\n\n"), + images, + files, + } +} + +function resolveFilePath(uri: string): string | undefined { + try { + const url = new URL(uri) + if (url.protocol === "file:") { + return fileURLToPath(url) + } + } catch (_error) { + if (path.isAbsolute(uri)) { + return uri + } + } + return undefined +} diff --git a/src/standalone/acp/convert.ts b/src/standalone/acp/convert.ts new file mode 100644 index 0000000000..0f35e1f436 --- /dev/null +++ b/src/standalone/acp/convert.ts @@ -0,0 +1,453 @@ +import * as path from "node:path" +import type { + SessionNotification, + ToolCallContent, + ToolCallLocation, + ToolCallStatus, + ToolCallUpdate, + ToolKind, +} from "@agentclientprotocol/sdk" +import type { ClineAsk, ClineMessage, ClineSayTool } from "@shared/ExtensionMessage" + +export type AcpConversionState = { + partialTextByKey: Map + partialMessageKeys: Set + toolCallIds: Map + lastCommandToolCallId?: string + lastBrowserToolCallId?: string +} + +export type ToolCallDetails = { + toolCallId: string + title: string + kind?: ToolKind + locations?: ToolCallLocation[] + rawInput?: Record + contentText?: string +} + +const PERMISSION_ASK_TYPES: Set = new Set(["tool", "command", "browser_action_launch", "use_mcp_server"]) + +const TOOL_KIND_BY_CLINE_TOOL: Record = { + readFile: "read", + listFilesTopLevel: "search", + listFilesRecursive: "search", + listCodeDefinitionNames: "search", + searchFiles: "search", + webSearch: "search", + webFetch: "fetch", + editedExistingFile: "edit", + newFileCreated: "edit", + fileDeleted: "delete", + summarizeTask: "think", +} + +const TOOL_TITLE_BY_CLINE_TOOL: Record = { + readFile: "Read file", + listFilesTopLevel: "List files", + listFilesRecursive: "List files recursively", + listCodeDefinitionNames: "List code definitions", + searchFiles: "Search files", + webSearch: "Search the web", + webFetch: "Fetch URL", + editedExistingFile: "Edit file", + newFileCreated: "Create file", + fileDeleted: "Delete file", + summarizeTask: "Summarize task", +} + +const ASK_FALLBACK_TEXT: Partial> = { + completion_result: "Task complete. Provide feedback or send the next prompt.", + resume_task: "Provide input to resume this task.", + resume_completed_task: "Provide input to resume this completed task.", + plan_mode_respond: "Provide feedback on the plan to continue.", + act_mode_respond: "Provide feedback to continue in act mode.", + command_output: "Provide input for the running command.", + mistake_limit_reached: "Cline needs guidance to continue. Provide next steps.", + api_req_failed: "The request failed. Provide guidance to retry or adjust.", + new_task: "Provide the next task.", + condense: "Provide input to continue after condensing.", + summarize_task: "Provide input to continue after summarizing.", + report_bug: "Provide details to report the bug.", +} + +export function createAcpConversionState(): AcpConversionState { + return { + partialTextByKey: new Map(), + partialMessageKeys: new Set(), + toolCallIds: new Map(), + } +} + +export function isPermissionAskType(ask?: ClineAsk): boolean { + return ask ? PERMISSION_ASK_TYPES.has(ask) : false +} + +export function messageKey(message: ClineMessage): string { + return `${message.ts}:${message.type}:${message.say ?? ""}:${message.ask ?? ""}` +} + +export function formatAskText(ask: ClineAsk | undefined, text: string | undefined): string | undefined { + if (text && text.trim().length > 0) { + return text + } + return ask ? ASK_FALLBACK_TEXT[ask] : undefined +} + +export function parseToolPayload(text: string | undefined): ClineSayTool | undefined { + if (!text) { + return undefined + } + try { + const parsed = JSON.parse(text) as Record + if (parsed && typeof parsed === "object" && typeof parsed.tool === "string") { + return parsed as ClineSayTool + } + } catch (_error) { + return undefined + } + return undefined +} + +function toolKindForToolName(tool?: string): ToolKind | undefined { + if (!tool) { + return undefined + } + return TOOL_KIND_BY_CLINE_TOOL[tool] +} + +function toolTitleForPayload(payload: ClineSayTool | undefined): string | undefined { + if (!payload) { + return undefined + } + const baseTitle = TOOL_TITLE_BY_CLINE_TOOL[payload.tool] ?? payload.tool + if (payload.path) { + return `${baseTitle}: ${payload.path}` + } + if (payload.regex) { + return `${baseTitle}: ${payload.regex}` + } + return baseTitle +} + +function toolLocationsForPayload(payload: ClineSayTool | undefined): ToolCallLocation[] | undefined { + if (!payload?.path) { + return undefined + } + if (!path.isAbsolute(payload.path)) { + return undefined + } + return [{ path: payload.path }] +} + +function toolCallIdForMessage(state: AcpConversionState, message: ClineMessage, prefix: string): string { + const key = messageKey(message) + const existing = state.toolCallIds.get(key) + if (existing) { + return existing + } + const id = `${prefix}-${message.ts}` + state.toolCallIds.set(key, id) + return id +} + +function contentBlocksForText(text?: string): ToolCallContent[] | undefined { + if (!text) { + return undefined + } + return [ + { + type: "content", + content: { + type: "text", + text, + }, + }, + ] +} + +export function buildToolCallDetailsFromMessage( + message: ClineMessage, + state: AcpConversionState, + options?: { + toolCallPrefix?: string + fallbackTitle?: string + }, +): ToolCallDetails | undefined { + const prefix = options?.toolCallPrefix ?? "cline-tool" + if (message.type === "say" && message.say === "command" && message.text) { + const toolCallId = toolCallIdForMessage(state, message, prefix) + state.lastCommandToolCallId = toolCallId + return { + toolCallId, + title: `Run command: ${message.text}`, + kind: "execute", + rawInput: { command: message.text }, + contentText: message.text, + } + } + + if ((message.type === "say" && message.say === "tool") || (message.type === "ask" && message.ask === "tool")) { + const payload = parseToolPayload(message.text) + const toolCallId = toolCallIdForMessage(state, message, prefix) + return { + toolCallId, + title: toolTitleForPayload(payload) ?? options?.fallbackTitle ?? "Tool request", + kind: toolKindForToolName(payload?.tool), + locations: toolLocationsForPayload(payload), + rawInput: payload ? { ...payload } : undefined, + contentText: payload ? JSON.stringify(payload, null, 2) : message.text, + } + } + + if (message.type === "say" && message.say === "browser_action") { + const toolCallId = toolCallIdForMessage(state, message, prefix) + state.lastBrowserToolCallId = toolCallId + return { + toolCallId, + title: "Browser action", + kind: "execute", + contentText: message.text, + } + } + + if (message.type === "ask" && message.ask === "browser_action_launch") { + const toolCallId = toolCallIdForMessage(state, message, prefix) + return { + toolCallId, + title: "Launch browser action", + kind: "execute", + contentText: message.text, + } + } + + if (message.type === "ask" && message.ask === "use_mcp_server") { + const toolCallId = toolCallIdForMessage(state, message, prefix) + return { + toolCallId, + title: "Use MCP server", + kind: "read", + contentText: message.text, + } + } + + return undefined +} + +export function buildNotificationsForMessage( + message: ClineMessage, + sessionId: string, + state: AcpConversionState, +): SessionNotification[] { + if (message.type === "ask") { + if (isPermissionAskType(message.ask)) { + return [] + } + const askText = formatAskText(message.ask, message.text) + if (!askText) { + return [] + } + return [ + { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: askText }, + }, + }, + ] + } + + if (message.type !== "say") { + return [] + } + + switch (message.say) { + case "reasoning": { + const reasoningText = message.reasoning ?? message.text + if (!reasoningText) { + return [] + } + return [ + { + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: reasoningText }, + }, + }, + ] + } + case "command": { + if (!message.text) { + return [] + } + const details = buildToolCallDetailsFromMessage(message, state, { toolCallPrefix: "cline-command" }) + if (!details) { + return [] + } + const status: ToolCallStatus = message.commandCompleted ? "completed" : "in_progress" + return [buildToolCallNotification(sessionId, details, status)] + } + case "command_output": { + if (state.lastCommandToolCallId && message.text) { + return [ + { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: state.lastCommandToolCallId, + status: "completed", + content: contentBlocksForText(message.text), + }, + }, + ] + } + return buildTextNotification(sessionId, message.text) + } + case "tool": { + const details = buildToolCallDetailsFromMessage(message, state, { toolCallPrefix: "cline-tool" }) + if (!details) { + return buildTextNotification(sessionId, message.text) + } + const status: ToolCallStatus = message.partial ? "in_progress" : "completed" + return [buildToolCallNotification(sessionId, details, status)] + } + case "browser_action": { + const details = buildToolCallDetailsFromMessage(message, state, { toolCallPrefix: "cline-browser" }) + if (!details) { + return buildTextNotification(sessionId, message.text) + } + return [buildToolCallNotification(sessionId, details, "completed")] + } + default: { + return buildTextNotification(sessionId, message.text) + } + } +} + +export function buildNotificationsForPartialMessage( + message: ClineMessage, + sessionId: string, + state: AcpConversionState, + isFinal: boolean, +): SessionNotification[] { + if (message.type !== "say") { + return [] + } + if (message.say !== "text" && message.say !== "reasoning") { + return [] + } + + const text = message.say === "reasoning" ? (message.reasoning ?? message.text) : message.text + if (!text) { + return [] + } + + const key = messageKey(message) + const delta = extractDelta(state.partialTextByKey, key, text) + state.partialMessageKeys.add(key) + + if (isFinal) { + state.partialTextByKey.delete(key) + } + + if (!delta) { + return [] + } + + const update: SessionNotification = { + sessionId, + update: { + sessionUpdate: message.say === "reasoning" ? "agent_thought_chunk" : "agent_message_chunk", + content: { type: "text", text: delta }, + }, + } + + return [update] +} + +export function shouldSkipStateMessage(message: ClineMessage, state: AcpConversionState): boolean { + if (message.partial) { + return true + } + const key = messageKey(message) + return state.partialMessageKeys.has(key) +} + +function extractDelta(cache: Map, key: string, text: string): string | undefined { + const previous = cache.get(key) ?? "" + if (text.startsWith(previous)) { + const delta = text.slice(previous.length) + cache.set(key, text) + return delta || undefined + } + cache.set(key, text) + return text +} + +function buildTextNotification(sessionId: string, text?: string): SessionNotification[] { + if (!text) { + return [] + } + return [ + { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, + }, + ] +} + +function buildToolCallNotification(sessionId: string, details: ToolCallDetails, status: ToolCallStatus): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: details.toolCallId, + title: details.title, + kind: details.kind, + status, + rawInput: details.rawInput, + locations: details.locations, + content: contentBlocksForText(details.contentText), + }, + } +} + +export function buildPermissionToolCall(details: ToolCallDetails): ToolCallUpdate { + return { + sessionUpdate: "tool_call_update", + toolCallId: details.toolCallId, + title: details.title, + kind: details.kind, + status: "pending", + rawInput: details.rawInput, + locations: details.locations, + content: contentBlocksForText(details.contentText), + } +} + +export function resolveClineModeId(mode: string | undefined): "plan" | "act" { + return mode === "act" ? "act" : "plan" +} + +export function buildModeState(currentMode: string | undefined) { + return { + currentModeId: resolveClineModeId(currentMode), + availableModes: [ + { + id: "plan", + name: "Plan", + description: "Plan and ask for confirmation before edits", + }, + { + id: "act", + name: "Act", + description: "Execute changes and tools when approved", + }, + ], + } +} diff --git a/src/standalone/acp/stdio.ts b/src/standalone/acp/stdio.ts new file mode 100644 index 0000000000..8c8cb26534 --- /dev/null +++ b/src/standalone/acp/stdio.ts @@ -0,0 +1,30 @@ +import { Readable, Writable } from "node:stream" +import { ReadableStream, WritableStream } from "node:stream/web" + +export function nodeToWebWritable(nodeStream: Writable): WritableStream { + return new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + nodeStream.write(Buffer.from(chunk), (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + }, + }) +} + +export function nodeToWebReadable(nodeStream: Readable): ReadableStream { + return new ReadableStream({ + start(controller) { + nodeStream.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) + nodeStream.on("end", () => controller.close()) + nodeStream.on("error", (err) => controller.error(err)) + }, + }) +} diff --git a/src/standalone/cline-acp.ts b/src/standalone/cline-acp.ts new file mode 100644 index 0000000000..145270e845 --- /dev/null +++ b/src/standalone/cline-acp.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { runAcp } from "./acp/acp-agent" + +// Ensure logs do not interfere with ACP stdio traffic +console.log = console.error +console.info = console.error +console.warn = console.error +console.debug = console.error + +process.on("unhandledRejection", (reason) => { + console.error("Unhandled rejection:", reason) +}) + +const args = parseArgs(process.argv.slice(2)) +if (args.version) { + const here = dirname(fileURLToPath(import.meta.url)) + const packagePath = resolve(here, "../../package.json") + const raw = readFileSync(packagePath, "utf8") + const json = JSON.parse(raw) as { version?: string } + const version = json.version ?? "0.0.0" + process.stdout.write(`${version}\n`) + process.exit(0) +} +if (args.config) { + process.env.CLINE_DIR = args.config +} + +runAcp() + +// Keep the process alive for stdio communication +process.stdin.resume() + +function parseArgs(argv: string[]): { config?: string; version?: boolean } { + const result: { config?: string; version?: boolean } = {} + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === "--config" || arg === "-c") { + result.config = argv[i + 1] + i += 1 + } else if (arg === "--version" || arg === "-v") { + result.version = true + } + } + return result +} diff --git a/src/test/acp-convert.test.ts b/src/test/acp-convert.test.ts new file mode 100644 index 0000000000..f46e0f064b --- /dev/null +++ b/src/test/acp-convert.test.ts @@ -0,0 +1,155 @@ +import { expect } from "chai" +import { describe, it } from "mocha" +import type { ClineMessage } from "@/shared/ExtensionMessage" +import { + buildNotificationsForMessage, + buildNotificationsForPartialMessage, + createAcpConversionState, +} from "@/standalone/acp/convert" + +describe("ACP conversion", () => { + it("maps say text to agent_message_chunk", () => { + const state = createAcpConversionState() + const message: ClineMessage = { + ts: 1, + type: "say", + say: "text", + text: "Hello", + } + + const notifications = buildNotificationsForMessage(message, "sess-1", state) + const update = notifications[0].update + + expect(notifications).to.have.length(1) + expect(update.sessionUpdate).to.equal("agent_message_chunk") + if (update.sessionUpdate !== "agent_message_chunk") { + throw new Error("Expected agent_message_chunk") + } + expect(update.content?.type).to.equal("text") + if (update.content?.type !== "text") { + throw new Error("Expected text content") + } + expect(update.content.text).to.equal("Hello") + }) + + it("maps reasoning to agent_thought_chunk", () => { + const state = createAcpConversionState() + const message: ClineMessage = { + ts: 2, + type: "say", + say: "reasoning", + reasoning: "Thinking", + } + + const notifications = buildNotificationsForMessage(message, "sess-1", state) + const update = notifications[0].update + + expect(notifications).to.have.length(1) + expect(update.sessionUpdate).to.equal("agent_thought_chunk") + if (update.sessionUpdate !== "agent_thought_chunk") { + throw new Error("Expected agent_thought_chunk") + } + expect(update.content?.type).to.equal("text") + if (update.content?.type !== "text") { + throw new Error("Expected text content") + } + expect(update.content.text).to.equal("Thinking") + }) + + it("maps tool messages to tool_call", () => { + const state = createAcpConversionState() + const payload = JSON.stringify({ tool: "readFile", path: "src/index.ts" }) + const message: ClineMessage = { + ts: 3, + type: "say", + say: "tool", + text: payload, + } + + const notifications = buildNotificationsForMessage(message, "sess-1", state) + const update = notifications[0].update + + expect(notifications).to.have.length(1) + expect(update.sessionUpdate).to.equal("tool_call") + if (update.sessionUpdate !== "tool_call") { + throw new Error("Expected tool_call") + } + expect(update.title).to.include("Read file") + expect(update.kind).to.equal("read") + }) + + it("links command output to last command tool call", () => { + const state = createAcpConversionState() + const commandMessage: ClineMessage = { + ts: 4, + type: "say", + say: "command", + text: "ls -la", + } + + const commandNotifications = buildNotificationsForMessage(commandMessage, "sess-1", state) + const commandUpdate = commandNotifications[0].update + expect(commandNotifications).to.have.length(1) + expect(commandUpdate.sessionUpdate).to.equal("tool_call") + if (commandUpdate.sessionUpdate !== "tool_call") { + throw new Error("Expected tool_call") + } + + const outputMessage: ClineMessage = { + ts: 5, + type: "say", + say: "command_output", + text: "output", + } + + const outputNotifications = buildNotificationsForMessage(outputMessage, "sess-1", state) + const outputUpdate = outputNotifications[0].update + expect(outputNotifications).to.have.length(1) + expect(outputUpdate.sessionUpdate).to.equal("tool_call_update") + if (outputUpdate.sessionUpdate !== "tool_call_update") { + throw new Error("Expected tool_call_update") + } + expect(outputUpdate.content?.[0]?.type).to.equal("content") + }) + + it("emits deltas for partial text", () => { + const state = createAcpConversionState() + const baseMessage: ClineMessage = { + ts: 6, + type: "say", + say: "text", + text: "Hello", + } + + const first = buildNotificationsForPartialMessage(baseMessage, "sess-1", state, false) + const firstUpdate = first[0].update + expect(first).to.have.length(1) + expect(firstUpdate.sessionUpdate).to.equal("agent_message_chunk") + if (firstUpdate.sessionUpdate !== "agent_message_chunk") { + throw new Error("Expected agent_message_chunk") + } + expect(firstUpdate.content?.type).to.equal("text") + if (firstUpdate.content?.type !== "text") { + throw new Error("Expected text content") + } + expect(firstUpdate.content.text).to.equal("Hello") + + const nextMessage: ClineMessage = { + ...baseMessage, + text: "Hello world", + } + + const second = buildNotificationsForPartialMessage(nextMessage, "sess-1", state, true) + const secondUpdate = second[0].update + expect(second).to.have.length(1) + expect(secondUpdate.sessionUpdate).to.equal("agent_message_chunk") + if (secondUpdate.sessionUpdate !== "agent_message_chunk") { + throw new Error("Expected agent_message_chunk") + } + expect(secondUpdate.content?.type).to.equal("text") + if (secondUpdate.content?.type !== "text") { + throw new Error("Expected text content") + } + expect(secondUpdate.content.text).to.equal(" world") + }) +})