diff --git a/README.md b/README.md index e47783275..6a2313610 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,20 @@ off. Restoring a session needs Herdr 0.9.2 or later. To start other agents in Herdr panes from Pi-Bolt, install [pi-herdr](https://www.npmjs.com/package/@andrewjacop/pi-herdr): `pi-bolt install npm:@andrewjacop/pi-herdr`. +## Editors + +`pi-bolt --acp` speaks the [Agent Client Protocol](https://agentclientprotocol.com), so Pi-Bolt runs in the agent panel of Zed, +JetBrains IDEs, VS Code (through an ACP extension), Neovim and other ACP editors, with your Pi settings, extensions, skills and +sessions. In Zed's `settings.json`: + +```json +"agent_servers": { + "Pi-Bolt": { "type": "custom", "command": "/full/path/to/pi-bolt", "args": ["--acp"], "env": {} } +} +``` + +Other editors, and what the editor gets: [docs/ACP.md](docs/ACP.md). + ## How it works @@ -247,6 +261,7 @@ in [`sources.json`](sources.json) with Pi-Bolt's changes on top; the engine chan | [Building](docs/BUILDING.md) | Building the runtime and Pi from source, training profiles, tests | | [Benchmarks](docs/BENCHMARKS.md) | Method, results and raw data | | [Plugins](docs/PLUGINS.md) | Porting Pi extensions and writing them for AOT | +| [Editors](docs/ACP.md) | Pi-Bolt in Zed, JetBrains IDEs, VS Code and other ACP editors | | [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnostics, environment variables, known limitations | | [Releasing](docs/RELEASING.md) | CI, releases, signing | diff --git a/VERSION b/VERSION index 7fc2521fd..1e9b46b22 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.6 +0.8.7 diff --git a/docs/ACP.md b/docs/ACP.md new file mode 100644 index 000000000..7e095471b --- /dev/null +++ b/docs/ACP.md @@ -0,0 +1,70 @@ +# Editors (Agent Client Protocol) + +`pi-bolt --acp` runs Pi-Bolt as an [Agent Client Protocol](https://agentclientprotocol.com) (ACP) agent: editors that speak ACP +start it and show it in their agent panel. That covers Zed, JetBrains IDEs, VS Code (through an ACP extension), Neovim, Emacs and +[the other ACP clients](https://agentclientprotocol.com/get-started/clients). It is built in, so there is nothing to install. The +ACP code loads only when `--acp` is given; the terminal UI, `-p`, and RPC mode do not load it. + +Editors launched from the desktop may not see your shell's `PATH`: give them the full path of the executable (`which pi-bolt`). + +## Setting it up + +**Zed**: in `settings.json`: + +```json +"agent_servers": { + "Pi-Bolt": { "type": "custom", "command": "/full/path/to/pi-bolt", "args": ["--acp"], "env": {} } +} +``` + +**JetBrains IDEs** (IntelliJ IDEA, PyCharm, WebStorm, ...): in the AI Chat tool window, choose *Add Custom Agent*, which opens +`~/.jetbrains/acp.json`, and add: + +```json +{ + "agent_servers": { + "Pi-Bolt": { "command": "/full/path/to/pi-bolt", "args": ["--acp"], "env": {} } + } +} +``` + +With `"use_idea_mcp": true` in its `default_mcp_settings`, the IDE also passes its own MCP server, and Pi-Bolt gets the IDE's +tools. + +**VS Code, Neovim, Emacs and others**: add a custom agent in the ACP client of your choice (for example the +[ACP Client](https://github.com/formulahendry/vscode-acp) extension for VS Code, or CodeCompanion for Neovim) with the command +`pi-bolt` and the argument `--acp`. + +Other flags work as on the command line, for example `["--acp", "--model", "anthropic/claude-sonnet-4-5"]` or +`["--acp", "--no-mcp"]`. + +## What the editor gets + +- **Your Pi setup.** Each editor session is a Pi session made as the terminal makes one: settings, models and logins + (`/login` in `pi-bolt`), extensions, skills, prompt templates, context files and `mcp.json` all apply. There is no separate + sign-in in the editor; when no model is set up, a prompt says how to set one up. +- **Streaming replies and thinking**, as the model writes them. +- **Tool calls** with a title, a kind (read, edit, run, search) and the files they touch, so the editor can follow along. Edits and + writes show as diffs, and commands show their output while they run (in a terminal view, with colours, in editors that + offer one, such as Zed). +- **Model and thinking level** as session settings the editor shows next to the prompt. Changing them affects that session only. +- **Slash commands**: extension commands, prompt templates, skills (`/skill:name`), and `/compact` and `/name`. +- **Context usage and cost** after each prompt. +- **Sessions**: Pi's own session files. The editor lists them (`session/list`), opens one with its history (`session/load`), or + continues one without it (`session/resume`). Sessions started in the editor open in the terminal with + `pi-bolt --session `, and the other way round. +- **The editor's MCP servers**, passed when a session starts, connect next to those of `mcp.json` (stdio and HTTP; legacy SSE is not + supported). `/mcp` lists them. A server of the same name in `mcp.json` takes precedence. +- **Images and files** in prompts: images are attached, `@` mentions read as the file's path, and embedded file contents are added to + the prompt as `` blocks. +- **Cancel** stops the model and any running tool. + +Pi runs its own tools on the files and runs commands in its own shell. Extensions that ask the user something reach the editor: +`confirm` and `select` as a permission request, `input` and `editor` as a form when the editor supports forms (otherwise they get +no answer), and `notify` as a notice when the editor shows notices. + +## Project trust + +A project with its own extensions, settings or packages in `.pi/` is trusted as in the other headless modes: by a decision saved +earlier, or by the `defaultProjectTrust` setting. ACP sessions cannot ask, so to trust a project, open it once with `pi-bolt` in a +terminal and trust it there. (`--approve` in the agent's arguments trusts every project the editor opens.) diff --git a/docs/releases/0.8.7.md b/docs/releases/0.8.7.md new file mode 100644 index 000000000..901eb1f5e --- /dev/null +++ b/docs/releases/0.8.7.md @@ -0,0 +1,64 @@ +Pi-Bolt 0.8.7 runs in editors: `pi-bolt --acp` serves the Agent Client Protocol. Everything else is [0.8.6](0.8.6.md). + +### Editors + +[Agent Client Protocol](https://agentclientprotocol.com) (ACP) editors start Pi-Bolt and show it in their agent panel: Zed, +JetBrains IDEs, VS Code (through an ACP extension), Neovim, Emacs and the other ACP clients. It is built in; point the editor at +`pi-bolt` with the argument `--acp`. In Zed's `settings.json`: + +```json +"agent_servers": { + "Pi-Bolt": { "type": "custom", "command": "/full/path/to/pi-bolt", "args": ["--acp"], "env": {} } +} +``` + +- **Your Pi setup:** each editor session is a Pi session with your settings, models and logins, extensions, skills, prompt + templates, context files and `mcp.json`. +- **Streaming replies and thinking**; **tool calls** with their kind and the files they touch, edits and writes as diffs, and + command output while it runs (in Zed, in a terminal view with colours). +- **Model and thinking level** as session settings; extension commands, prompt templates, skills, `/compact` and `/name` as slash + commands; context usage and cost after each prompt. +- **Sessions** are Pi's own session files: the editor lists them, opens one with its history or continues it, and a session + started in the editor opens in the terminal with `pi-bolt --session `. +- **The editor's MCP servers** connect next to those of `mcp.json`. +- **Extension dialogs** reach the editor: confirm and select as permission requests, input and editor as forms, notify as notices. + +The ACP code loads only with `--acp`: the terminal UI, `-p` and RPC mode start and run as in 0.8.6. Setting up other editors: +[docs/ACP.md](https://github.com/opensec-git/Pi-Bolt/blob/pi-bolt/docs/ACP.md). + +### Windows + +The Windows builds' prebuilt heap is laid out in the order Pi 1.2.0 uses it, recorded on GitHub's Windows runner. + +### Engine + +Unchanged since 0.8.5: this release's runtimes are [0.8.5's](0.8.5.md#engine). + +### Install + +```bash +curl -fsSL https://pi-bolt.opensec.in/install.sh | sh +``` + +```powershell +irm https://pi-bolt.opensec.in/install.ps1 | iex +``` + +```bat +powershell -c "irm https://pi-bolt.opensec.in/install.ps1 | iex" +``` + +or `pi-bolt update`, or `npm install -g --allow-scripts=pi-bolt pi-bolt`, or `bun add -g --trust pi-bolt`. + +| Asset | For | +|---|---| +| `pi-bolt-linux-x64.tar.xz` / `.tar.gz` | **Recommended on Linux.** CPUs with AVX2 (Intel Haswell 2013+, AMD Zen+). | +| `pi-bolt-linux-x64-baseline.tar.xz` / `.tar.gz` | Any x86-64 CPU with SSE4.2. | +| `pi-bolt-linux-x64-jit.tar.xz` / `.tar.gz` | JIT also on, for plugins loaded at run time. | +| `pi-bolt-darwin-arm64.tar.xz` / `.tar.gz` | **macOS.** Macs with Apple silicon, macOS 13 or later. | +| `pi-bolt-darwin-arm64-jit.tar.xz` / `.tar.gz` | macOS, JIT also on. | +| `pi-bolt-win32-x64.zip` | **Windows.** Windows 10 (1809) or 11 on x64; compiled code on AVX2 CPUs, bytecode on others. | +| `pi-bolt-win32-x64-jit.zip` | Windows, JIT also on. | +| `pi-bolt-runtime-linux-x64.tar.gz`, `pi-bolt-runtime-darwin-arm64.tar.gz`, `pi-bolt-runtime-win32-x64.zip` | The Pi-Bolt Bun runtime, for compiling plugins in. | + +Linux x86-64 with glibc 2.17 or later; macOS 13 or later on Apple silicon; Windows 10 1809 or later, or Windows 11, on x64. diff --git a/npm/bin/pi-bolt b/npm/bin/pi-bolt index f19076fde..21e9dff47 100755 --- a/npm/bin/pi-bolt +++ b/npm/bin/pi-bolt @@ -4,7 +4,7 @@ # replaces this script with the native executable (exec), so the launcher costs no startup time. set -eu -VERSION=0.8.6 +VERSION=0.8.7 HOME_DIR="${PIBOLT_HOME:-$HOME/.pi-bolt}" if [ "$(uname -s)" = Darwin ]; then PLATFORM=darwin; else PLATFORM=linux; fi diff --git a/npm/package.json b/npm/package.json index 438e51d8e..7d5fed95b 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "pi-bolt", - "version": "0.8.6", + "version": "0.8.7", "description": "Pi, the coding agent, compiled ahead of time to native code: one executable for Linux x86-64, macOS on Apple silicon and Windows x64, starts 2-3x sooner than Pi on Bun and uses about a third of its CPU over a session", "keywords": [ "pi", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 9e996f374..854d94599 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -8,7 +8,7 @@ import { COMMAND_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR, PRODUCT_ import type { ExtensionFlag } from "../core/extensions/types.ts"; import { getToolListError, type TuiMode } from "../core/settings-manager.ts"; -export type Mode = "text" | "json" | "rpc"; +export type Mode = "text" | "json" | "rpc" | "acp"; export interface Args { provider?: string; @@ -96,18 +96,20 @@ export function parseArgs(args: string[]): Args { } else if (arg === "--mode") { const mode = args[i + 1]; if (mode === undefined || mode.startsWith("-")) { - result.diagnostics.push({ type: "error", message: "--mode requires text, json, or rpc" }); + result.diagnostics.push({ type: "error", message: "--mode requires text, json, rpc, or acp" }); continue; } i++; - if (mode !== "text" && mode !== "json" && mode !== "rpc") { + if (mode !== "text" && mode !== "json" && mode !== "rpc" && mode !== "acp") { result.diagnostics.push({ type: "error", - message: `Invalid mode "${mode}". Valid values: text, json, rpc`, + message: `Invalid mode "${mode}". Valid values: text, json, rpc, acp`, }); continue; } result.mode = mode; + } else if (arg === "--acp") { + result.mode = "acp"; } else if (arg === "--continue" || arg === "-c") { result.continue = true; } else if (arg === "--resume" || arg === "-r") { @@ -303,7 +305,8 @@ ${chalk.bold("Options:")} --api-key API key (defaults to env vars) --system-prompt System prompt (default: coding assistant prompt) --append-system-prompt Append text or file contents to the system prompt (can be used multiple times) - --mode Output mode: text (default), json, or rpc + --mode Output mode: text (default), json, rpc, or acp + --acp Agent Client Protocol server on stdin/stdout, for editors (same as --mode acp) --print, -p Non-interactive mode: process prompt and exit --continue, -c Continue previous session --resume, -r Select a session to resume diff --git a/packages/coding-agent/src/extensions/acp/connection.ts b/packages/coding-agent/src/extensions/acp/connection.ts new file mode 100644 index 000000000..db8501226 --- /dev/null +++ b/packages/coding-agent/src/extensions/acp/connection.ts @@ -0,0 +1,150 @@ +/** + * JSON-RPC 2.0 over newline-delimited JSON, both ways: ACP clients call the agent's methods, and the agent calls the + * client's (permission requests, elicitation). Requests are served concurrently, so `session/cancel` and the other + * methods are answered while a `session/prompt` is still running. + */ + +import { AcpErrorCode } from "./types.ts"; + +type JsonRpcId = string | number; + +/** Thrown by method handlers to answer with a specific JSON-RPC error. */ +export class AcpError extends Error { + readonly code: number; + readonly data?: unknown; + + constructor(code: number, message: string, data?: unknown) { + super(message); + this.name = "AcpError"; + this.code = code; + this.data = data; + } +} + +export type AcpRequestHandler = (method: string, params: unknown) => Promise; +export type AcpNotificationHandler = (method: string, params: unknown) => void; + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +} + +export class AcpConnection { + private readonly write: (line: string) => void; + private readonly onRequest: AcpRequestHandler; + private readonly onNotification: AcpNotificationHandler; + private readonly pending = new Map(); + private nextId = 0; + private closed = false; + + constructor(options: { + write: (line: string) => void; + onRequest: AcpRequestHandler; + onNotification: AcpNotificationHandler; + }) { + this.write = options.write; + this.onRequest = options.onRequest; + this.onNotification = options.onNotification; + } + + /** Handles one line received from the client. */ + receive(line: string): void { + if (line.trim() === "") return; + let message: unknown; + try { + message = JSON.parse(line); + } catch (error) { + this.send({ jsonrpc: "2.0", id: null, error: { code: AcpErrorCode.ParseError, message: errorText(error) } }); + return; + } + if (!isRecord(message)) { + this.send({ + jsonrpc: "2.0", + id: null, + error: { code: AcpErrorCode.InvalidRequest, message: "Not a JSON-RPC message" }, + }); + return; + } + const id = message.id; + const hasId = typeof id === "string" || typeof id === "number"; + if (typeof message.method === "string") { + if (hasId) void this.serve(id, message.method, message.params); + else this.notification(message.method, message.params); + return; + } + if (hasId && ("result" in message || "error" in message)) { + const request = this.pending.get(id); + if (!request) return; + this.pending.delete(id); + if (isRecord(message.error)) { + request.reject( + new AcpError( + typeof message.error.code === "number" ? message.error.code : AcpErrorCode.InternalError, + typeof message.error.message === "string" ? message.error.message : "Client error", + message.error.data, + ), + ); + } else { + request.resolve(message.result); + } + } + } + + /** Calls a client method and waits for its result. */ + request(method: string, params: unknown): Promise { + if (this.closed) return Promise.reject(new Error("The ACP connection is closed")); + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject }); + this.send({ jsonrpc: "2.0", id, method, params }); + }); + } + + notify(method: string, params: unknown): void { + if (this.closed) return; + this.send({ jsonrpc: "2.0", method, params }); + } + + /** Fails the requests still waiting for the client, which has gone. */ + close(): void { + this.closed = true; + for (const request of this.pending.values()) request.reject(new Error("The ACP connection is closed")); + this.pending.clear(); + } + + private async serve(id: JsonRpcId, method: string, params: unknown): Promise { + try { + const result = await this.onRequest(method, params); + this.send({ jsonrpc: "2.0", id, result: result ?? null }); + } catch (error) { + const code = error instanceof AcpError ? error.code : AcpErrorCode.InternalError; + const data = error instanceof AcpError ? error.data : undefined; + this.send({ + jsonrpc: "2.0", + id, + error: data === undefined ? { code, message: errorText(error) } : { code, message: errorText(error), data }, + }); + } + } + + private notification(method: string, params: unknown): void { + try { + this.onNotification(method, params); + } catch { + // Notifications have no response to carry an error. + } + } + + private send(message: object): void { + if (this.closed) return; + this.write(`${JSON.stringify(message)}\n`); + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/coding-agent/src/extensions/acp/extension.ts b/packages/coding-agent/src/extensions/acp/extension.ts new file mode 100644 index 000000000..e61ce95ec --- /dev/null +++ b/packages/coding-agent/src/extensions/acp/extension.ts @@ -0,0 +1,80 @@ +/** + * The extension ACP mode adds to each of its sessions: it gives Pi's MCP support (extensions/mcp) the MCP servers the + * editor passed with `session/new` or `session/load`, such as an IDE's own tool server, next to the servers of `mcp.json`. + * They connect and are exposed like any other server (`/mcp` lists them); a server of the same name in `mcp.json` wins. + */ + +import type { InlineExtension } from "../../core/extensions/types.ts"; +import type { McpServerConfig } from "../../core/mcp-servers.ts"; +import type { McpServer } from "./types.ts"; + +/** MCP servers by Pi session id, set by ACP mode before it creates or loads the session. */ +const sessionMcpServers = new Map(); + +export function setSessionMcpServers(sessionId: string, servers: readonly McpServer[] | undefined): void { + if (servers && servers.length > 0) sessionMcpServers.set(sessionId, [...servers]); + else sessionMcpServers.delete(sessionId); +} + +/** + * Pi resolves `${NAME}` and `!command` in MCP header and env values; values from the editor are taken as they are. + */ +export function literalConfigValue(value: string): string { + const escaped = value.replace(/\$/g, "$$$$"); + return escaped.startsWith("!") ? `$${escaped}` : escaped; +} + +/** A name Pi accepts for an MCP server: letters, digits, "_" and "-". */ +export function mcpServerName(name: string): string { + const cleaned = name.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); + return cleaned || "server"; +} + +/** Pi's config for an ACP MCP server, or undefined for a transport Pi does not have (legacy SSE). */ +export function toMcpServerConfig(server: McpServer): McpServerConfig | undefined { + if (server.type === "sse") return undefined; + if (server.type === "http") { + return { + type: "http", + url: server.url, + headers: Object.fromEntries(server.headers.map((header) => [header.name, literalConfigValue(header.value)])), + }; + } + return { + type: "stdio", + command: server.command, + args: server.args, + env: Object.fromEntries( + (server.env ?? []).map((variable) => [variable.name, literalConfigValue(variable.value)]), + ), + }; +} + +export const acpExtension: InlineExtension = { + name: "acp", + builtin: true, + factory: (pi) => { + let registered: string[] = []; + pi.on("session_start", (_event, ctx) => { + for (const name of registered) pi.unregisterMcpServer(name); + registered = []; + const taken = new Set(pi.getMcpServers().map((server) => server.name)); + for (const server of sessionMcpServers.get(ctx.sessionManager.getSessionId()) ?? []) { + const config = toMcpServerConfig(server); + if (!config) { + ctx.ui.notify(`MCP server "${server.name}": legacy SSE servers are not supported`, "warning"); + continue; + } + let name = mcpServerName(server.name); + for (let i = 2; taken.has(name); i++) name = `${mcpServerName(server.name)}-${i}`; + try { + pi.registerMcpServer(name, config); + taken.add(name); + registered.push(name); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning"); + } + } + }); + }, +}; diff --git a/packages/coding-agent/src/extensions/acp/mode.lazy.ts b/packages/coding-agent/src/extensions/acp/mode.lazy.ts new file mode 100644 index 000000000..5ca222ac2 --- /dev/null +++ b/packages/coding-agent/src/extensions/acp/mode.lazy.ts @@ -0,0 +1,2 @@ +/** Loads the Agent Client Protocol server only for `--acp` (see mode.ts). */ +export const loadAcpMode = () => import("./mode.ts"); diff --git a/packages/coding-agent/src/extensions/acp/mode.ts b/packages/coding-agent/src/extensions/acp/mode.ts new file mode 100644 index 000000000..e3b27a872 --- /dev/null +++ b/packages/coding-agent/src/extensions/acp/mode.ts @@ -0,0 +1,1022 @@ +/** + * ACP mode (`--acp`): Pi as an Agent Client Protocol agent (https://agentclientprotocol.com), so editors that speak ACP + * (Zed, JetBrains IDEs, VS Code and Neovim through their ACP clients) run Pi in their agent panel. The editor starts the + * process and talks JSON-RPC over stdin and stdout. + * + * Every ACP session is a Pi session of its own, created as the other modes create theirs (the same runtime factory, so + * settings, extensions, skills, prompt templates, MCP servers, and project trust apply as usual) in the directory the + * editor names. Sessions are Pi's session files: `session/list` and `session/load` find the sessions of the terminal too, + * and a session started in the editor resumes with `pi --session`. Pi runs its own tools; the editor sees each call with + * its file locations, edits as diffs, and command output (in a terminal view, for editors that take one). Extension + * dialogs reach the user as permission requests (confirm, select) and forms (input, editor), when the editor offers them. + */ + +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import type { Readable } from "node:stream"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { COMMAND_NAME, getDocsPath, PRODUCT_NAME, VERSION } from "../../config.ts"; +import type { AgentSession } from "../../core/agent-session.ts"; +import { + type AgentSessionRuntime, + type CreateAgentSessionRuntimeFactory, + createAgentSessionRuntime, +} from "../../core/agent-session-runtime.ts"; +import { formatNoApiKeyFoundMessage } from "../../core/auth-guidance.ts"; +import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../core/extensions/index.ts"; +import { + flushRawStdout, + takeOverStdout, + waitForRawStdoutBackpressure, + writeRawStdout, +} from "../../core/output-guard.ts"; +import { type SessionInfo as PiSessionInfo, SessionManager } from "../../core/session-manager.ts"; +import { SettingsManager } from "../../core/settings-manager.ts"; +import { theme } from "../../modes/interactive/theme/theme.ts"; +import { attachJsonlLineReader } from "../../modes/rpc/jsonl.ts"; +import { killTrackedDetachedChildren } from "../../utils/shell.ts"; +import { AcpConnection, AcpError, errorText, isRecord } from "./connection.ts"; +import { setSessionMcpServers } from "./extension.ts"; +import { + bashExitCode, + bashOutputTail, + bashResultContent, + fenced, + type OutputTail, + promptToPi, + replayMessages, + resultText, + runError, + stopReasonOf, + TERMINAL_START, + type TerminalSent, + terminalOutputDelta, + toolCallInfo, + toolPath, + toolResultContent, +} from "./translate.ts"; +import { + ACP_PROTOCOL_VERSION, + AcpErrorCode, + type AvailableCommand, + type ClientCapabilities, + type ConfigOption, + type ContentBlock, + type CreateElicitationResponse, + type InitializeResponse, + type McpServer, + type PermissionOption, + type RequestPermissionResponse, + type SessionInfo, + type SessionUpdate, + type StopReason, + type ToolCallContent, +} from "./types.ts"; + +export { acpExtension } from "./extension.ts"; + +export interface AcpModeOptions { + /** The runtime factory of main.ts, which builds each session as the other modes build theirs. */ + createRuntime: CreateAgentSessionRuntimeFactory; + agentDir: string; + /** Session directory from `--session-dir` or the environment; otherwise each directory's setting or default. */ + sessionDir?: string; + /** `--offline`: no model catalog refresh. */ + offline?: boolean; +} + +/** Files larger than this get no diff (an editor would not show it well, and reading it costs). */ +const MAX_DIFF_BYTES = 1024 * 1024; +/** Live command output sent while a command runs: the end of it, at most this many characters, every 250 ms. */ +const MAX_LIVE_OUTPUT_CHARS = 64 * 1024; +const LIVE_OUTPUT_INTERVAL_MS = 250; +const SESSION_LIST_PAGE_SIZE = 50; + +/** Built-in commands ACP mode runs itself; Pi's other built-in commands need its terminal UI. */ +const ACP_COMMANDS: AvailableCommand[] = [ + { + name: "compact", + description: "Compact the conversation", + input: { hint: "optional instructions for the summary" }, + }, + { name: "name", description: "Name this session", input: { hint: "session name" } }, +]; + +/** Runs ACP on stdin and stdout until the editor closes them. */ +export async function runAcpMode(options: AcpModeOptions): Promise { + takeOverStdout(); + const agent = new AcpAgent(options, (line) => writeRawStdout(line)); + let shuttingDown = false; + const shutdown = async (exitCode: number, flush: boolean) => { + if (shuttingDown) process.exit(exitCode); + shuttingDown = true; + detach(); + process.stdin.pause(); + await agent.dispose(); + if (flush) await flushRawStdout(); + process.exit(exitCode); + }; + const signals: NodeJS.Signals[] = process.platform === "win32" ? ["SIGTERM"] : ["SIGTERM", "SIGHUP"]; + for (const signal of signals) { + process.on(signal, () => { + killTrackedDetachedChildren(); + void shutdown(signal === "SIGHUP" ? 129 : 143, false); + }); + } + const detach = agent.attach(process.stdin, () => void shutdown(0, true)); + return new Promise(() => {}); +} + +/** The agent side of one ACP connection. Exported for tests, which drive it without stdio. */ +export class AcpAgent { + readonly connection: AcpConnection; + private readonly options: AcpModeOptions; + private readonly sessions = new Map(); + private clientCapabilities: ClientCapabilities = {}; + /** Session creation runs one at a time: extensions load and project trust is decided per session. */ + private creating: Promise = Promise.resolve(); + private refreshedModels = false; + + constructor(options: AcpModeOptions, write: (line: string) => void) { + this.options = options; + this.connection = new AcpConnection({ + write, + onRequest: (method, params) => this.handleRequest(method, params), + onNotification: (method, params) => this.handleNotification(method, params), + }); + } + + /** Reads the client's messages from `input`; `onEnd` runs when it closes. Returns a function that stops reading. */ + attach(input: Readable, onEnd: () => void): () => void { + const detachLines = attachJsonlLineReader(input, (line) => this.connection.receive(line)); + input.on("end", onEnd); + return () => { + detachLines(); + input.off("end", onEnd); + }; + } + + get capabilities(): ClientCapabilities { + return this.clientCapabilities; + } + + async dispose(): Promise { + const sessions = [...this.sessions.values()]; + this.sessions.clear(); + await Promise.allSettled(sessions.map((session) => session.dispose())); + this.connection.close(); + } + + sendUpdate(sessionId: string, update: SessionUpdate): void { + this.connection.notify("session/update", { sessionId, update }); + } + + private async handleRequest(method: string, raw: unknown): Promise { + const params = isRecord(raw) ? raw : {}; + switch (method) { + case "initialize": + return this.initialize(params); + case "authenticate": + // No authentication methods are advertised: Pi uses its own credentials (`/login`, API keys). + return {}; + case "session/new": + return this.newSession(params); + case "session/load": + return this.loadSession(params, true); + case "session/resume": + return this.loadSession(params, false); + case "session/list": + return this.listSessions(params); + case "session/close": { + const session = this.session(params); + this.sessions.delete(session.id); + await session.dispose(); + return {}; + } + case "session/prompt": + return this.session(params).prompt(Array.isArray(params.prompt) ? (params.prompt as ContentBlock[]) : []); + case "session/set_config_option": + return { + configOptions: await this.session(params).setConfigOption( + requireString(params, "configId"), + params.value, + ), + }; + default: + throw new AcpError(AcpErrorCode.MethodNotFound, `Method not found: ${method}`); + } + } + + private handleNotification(method: string, raw: unknown): void { + if (method === "session/cancel" && isRecord(raw) && typeof raw.sessionId === "string") { + this.sessions.get(raw.sessionId)?.cancel(); + } + } + + private initialize(params: Record): InitializeResponse { + this.clientCapabilities = isRecord(params.clientCapabilities) + ? (params.clientCapabilities as ClientCapabilities) + : {}; + return { + protocolVersion: ACP_PROTOCOL_VERSION, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true, audio: false, embeddedContext: true }, + mcpCapabilities: { http: true, sse: false }, + sessionCapabilities: { list: {}, resume: {}, close: {} }, + }, + agentInfo: { name: COMMAND_NAME, title: PRODUCT_NAME, version: VERSION }, + authMethods: [], + }; + } + + private session(params: Record): AcpSession { + const id = requireString(params, "sessionId"); + const session = this.sessions.get(id); + if (!session) throw new AcpError(AcpErrorCode.ResourceNotFound, `Session not found: ${id}`); + return session; + } + + /** Session directory for sessions in `cwd`: `--session-dir`, else the user's `sessionDir` setting, else Pi's default. */ + private sessionDirFor(cwd: string): string | undefined { + return ( + this.options.sessionDir ?? + SettingsManager.create(cwd, this.options.agentDir, { projectTrusted: false }).getSessionDir() + ); + } + + private async newSession(params: Record) { + const cwd = requireDirectory(params); + const sessionManager = SessionManager.create(cwd, this.sessionDirFor(cwd)); + const session = await this.open(sessionManager, mcpServersOf(params), false); + return { sessionId: session.id, configOptions: session.configOptions() }; + } + + private async loadSession(params: Record, replay: boolean) { + const id = requireString(params, "sessionId"); + const cwd = requireDirectory(params); + const open = this.sessions.get(id); + if (open) { + if (replay) open.replay(); + return { configOptions: open.configOptions() }; + } + const sessionDir = this.sessionDirFor(cwd); + const path = SessionManager.findById(cwd, id, sessionDir) ?? (await findSessionFile(id, this.options.sessionDir)); + if (!path) throw new AcpError(AcpErrorCode.ResourceNotFound, `Session not found: ${id}`); + const session = await this.open(SessionManager.open(path, sessionDir, cwd), mcpServersOf(params), true); + if (replay) session.replay(); + return { configOptions: session.configOptions() }; + } + + private async listSessions(params: Record) { + const cwd = typeof params.cwd === "string" && params.cwd !== "" ? params.cwd : undefined; + // (Sessions of old Pi versions have no directory, which ACP requires.) + const all = ( + cwd + ? await SessionManager.list(cwd, this.sessionDirFor(cwd)) + : await SessionManager.listAll(this.options.sessionDir) + ).filter((info) => info.cwd !== ""); + let start = 0; + if (typeof params.cursor === "string" && params.cursor !== "") { + start = Number.parseInt(params.cursor, 10); + if (!Number.isSafeInteger(start) || start < 0) + throw new AcpError(AcpErrorCode.InvalidParams, "Invalid cursor"); + } + const page = all.slice(start, start + SESSION_LIST_PAGE_SIZE); + const next = start + page.length; + return { + sessions: page.map(sessionInfo), + ...(next < all.length ? { nextCursor: String(next) } : {}), + }; + } + + private async open(sessionManager: SessionManager, mcpServers: McpServer[], resumed: boolean): Promise { + const create = this.creating.then(async () => { + const id = sessionManager.getSessionId(); + setSessionMcpServers(id, mcpServers); + const runtime = await createAgentSessionRuntime(this.options.createRuntime, { + cwd: sessionManager.getCwd(), + agentDir: this.options.agentDir, + sessionManager, + sessionStartEvent: resumed ? { type: "session_start", reason: "resume" } : undefined, + }); + for (const diagnostic of runtime.diagnostics) { + console.error(`${diagnostic.type === "error" ? "Error" : "Warning"}: ${diagnostic.message}`); + } + // As the other modes exit on them: a session whose extension failed to load, or whose `--provider` is unknown + // (it would run on another model), is not the session asked for. + const errors = runtime.diagnostics.filter((diagnostic) => diagnostic.type === "error"); + if (errors.length > 0) { + setSessionMcpServers(id, undefined); + await runtime.dispose(); + throw new AcpError(AcpErrorCode.InternalError, errors.map((error) => error.message).join("\n")); + } + const session = new AcpSession(this, id, runtime); + this.sessions.set(id, session); + await session.bind(); + if (!this.refreshedModels && !this.options.offline) { + this.refreshedModels = true; + void runtime.services.modelRuntime.refresh({ signal: AbortSignal.timeout(15_000) }).catch(() => {}); + } + // After the response that tells the client the session exists. + setTimeout(() => session.announce(), 0); + return session; + }); + this.creating = create.catch(() => {}); + return create; + } +} + +/** One ACP session: a Pi session runtime and the translation of its events. */ +class AcpSession { + readonly id: string; + private readonly agent: AcpAgent; + private readonly runtime: AgentSessionRuntime; + private unsubscribe?: () => void; + private unsubscribeBackpressure?: () => void; + /** Prompts of this session, one after the other. */ + private queue: Promise = Promise.resolve(); + private cancelled = false; + private messageCount = 0; + private messageId: string | undefined; + /** File contents before an edit or write, for its diff. */ + private readonly before = new Map(); + private readonly runningTools = new Map(); + /** Output of running commands: the end of it for a text view, or the latest report for a terminal. */ + private readonly liveOutput = new Map< + string, + { timer?: ReturnType; text?: string; tail?: OutputTail } + >(); + /** Commands shown in a terminal view, with what each terminal has been sent. */ + private readonly terminals = new Map(); + private disposed = false; + + constructor(agent: AcpAgent, id: string, runtime: AgentSessionRuntime) { + this.agent = agent; + this.id = id; + this.runtime = runtime; + } + + private get session(): AgentSession { + return this.runtime.session; + } + + private get cwd(): string { + return this.runtime.cwd; + } + + private send(update: SessionUpdate): void { + if (!this.disposed) this.agent.sendUpdate(this.id, update); + } + + async bind(): Promise { + this.runtime.setRebindSession(async () => { + await this.rebind(); + this.announce(); + }); + await this.rebind(); + } + + private async rebind(): Promise { + const session = this.session; + await session.bindExtensions({ + uiContext: this.createUIContext(), + // Extensions see a headless host whose dialogs a client answers, as in RPC mode. + mode: "rpc", + commandContextActions: { + waitForIdle: () => this.session.waitForIdle(), + newSession: async (options) => this.runtime.newSession(options), + fork: async (entryId, forkOptions) => { + const result = await this.runtime.fork(entryId, forkOptions); + return { cancelled: result.cancelled }; + }, + navigateTree: async (targetId, options) => { + const result = await this.session.navigateTree(targetId, { + summarize: options?.summarize, + customInstructions: options?.customInstructions, + replaceInstructions: options?.replaceInstructions, + label: options?.label, + }); + return { cancelled: result.cancelled }; + }, + switchSession: async (sessionPath, options) => this.runtime.switchSession(sessionPath, options), + reload: async () => { + await this.session.reload(); + this.announce(); + }, + }, + onError: (err) => console.error(`Extension error (${err.extensionPath}): ${err.error}`), + }); + this.unsubscribe?.(); + this.unsubscribeBackpressure?.(); + this.unsubscribe = session.subscribe((event) => this.onEvent(event)); + this.unsubscribeBackpressure = session.agent.subscribe(async () => { + await waitForRawStdoutBackpressure(); + }); + } + + /** Sends what the editor shows next to the session: its commands, settings, title, and context use. */ + announce(): void { + this.send({ sessionUpdate: "available_commands_update", availableCommands: this.availableCommands() }); + this.send({ sessionUpdate: "config_option_update", configOptions: this.configOptions() }); + const title = this.session.sessionName; + if (title) this.send({ sessionUpdate: "session_info_update", title }); + this.sendUsage(); + } + + replay(): void { + for (const update of replayMessages(this.session.messages, this.cwd)) this.send(update); + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + for (const live of this.liveOutput.values()) clearTimeout(live.timer); + this.unsubscribe?.(); + this.unsubscribeBackpressure?.(); + await this.runtime.dispose(); + setSessionMcpServers(this.id, undefined); + } + + cancel(): void { + this.cancelled = true; + void this.session.abort(); + } + + prompt(blocks: ContentBlock[]): Promise<{ stopReason: StopReason }> { + const run = this.queue.then(() => this.runPrompt(blocks)); + this.queue = run.catch(() => {}); + return run; + } + + private async runPrompt(blocks: ContentBlock[]): Promise<{ stopReason: StopReason }> { + this.cancelled = false; + const { text, images } = promptToPi(blocks); + const command = /^\/(compact|name)(?:\s+([\s\S]*))?$/.exec(text.trim()); + if (command && images.length === 0) { + await this.runCommand(command[1], command[2]?.trim() ?? ""); + return { stopReason: this.cancelled ? "cancelled" : "end_turn" }; + } + const session = this.session; + if (!session.model) { + throw new AcpError(AcpErrorCode.InternalError, `No models available. ${setupHelp()}`); + } + const start = session.messages.length; + try { + await session.prompt(text, { images: images.length > 0 ? images : undefined, source: "rpc" }); + await session.waitForIdle(); + } catch (error) { + if (this.cancelled) return { stopReason: "cancelled" }; + // Pi's own message points to /login, which the editor does not have. (Not AuthRequired: Zed shows its own + // message for that, and ACP mode has no authentication methods to offer.) + const provider = session.model?.provider; + if (provider && errorText(error) === formatNoApiKeyFoundMessage(provider)) { + // (A provider of "unknown" is the placeholder model of a Pi with nothing set up.) + const problem = provider === "unknown" ? "No model is set up." : `No API key found for ${provider}.`; + throw new AcpError(AcpErrorCode.InternalError, `${problem} ${setupHelp()}`); + } + throw error; + } finally { + this.sendUsage(); + } + const messages = session.messages.slice(start); + const error = this.cancelled ? undefined : runError(messages); + if (error) throw new AcpError(AcpErrorCode.InternalError, error); + return { stopReason: stopReasonOf(messages, this.cancelled) }; + } + + private async runCommand(name: string, argument: string): Promise { + const reply = (text: string) => + this.send({ sessionUpdate: "agent_message_chunk", messageId: randomUUID(), content: { type: "text", text } }); + if (name === "name") { + if (!argument) { + reply(this.session.sessionName ? `Session name: ${this.session.sessionName}` : "Usage: /name "); + return; + } + this.session.setSessionName(argument); + reply(`Session named "${argument}".`); + return; + } + try { + const result = await this.session.compact(argument || undefined); + reply(`Compacted the conversation (${result.tokensBefore.toLocaleString("en-US")} tokens before).`); + } catch (error) { + if (this.cancelled) return; + throw new AcpError(AcpErrorCode.InternalError, errorText(error)); + } finally { + this.sendUsage(); + } + } + + private sendUsage(): void { + const usage = this.session.getContextUsage(); + if (!usage || usage.tokens === null) return; + const cost = this.session.getSessionStats().cost; + this.send({ + sessionUpdate: "usage_update", + used: usage.tokens, + size: usage.contextWindow, + ...(cost > 0 ? { cost: { amount: cost, currency: "USD" } } : {}), + }); + } + + private availableCommands(): AvailableCommand[] { + const commands = new Map(); + const add = (name: string, description: string | undefined, hint?: string) => { + if (!commands.has(name)) + commands.set(name, { name, description: description || name, input: hint ? { hint } : null }); + }; + for (const command of this.session.extensionRunner.getRegisteredCommands()) { + add(command.invocationName, command.description); + } + for (const template of this.session.promptTemplates) + add(template.name, template.description, template.argumentHint); + for (const skill of this.session.resourceLoader.getSkills().skills) add(`skill:${skill.name}`, skill.description); + for (const command of ACP_COMMANDS) if (!commands.has(command.name)) commands.set(command.name, command); + return [...commands.values()]; + } + + /** The models to choose from: those of `--models` or the `enabledModels` setting, as in the terminal, else all. */ + private selectableModels(): readonly Model[] { + const scoped = this.session.scopedModels; + return scoped.length > 0 ? scoped.map((entry) => entry.model) : this.session.modelRuntime.getAvailableSnapshot(); + } + + configOptions(): ConfigOption[] { + const options: ConfigOption[] = []; + const session = this.session; + const models = this.selectableModels(); + const current = session.model; + if (models.length > 0 && current) { + const choices = models.map((model) => ({ + value: modelKey(model), + name: model.name || model.id, + description: model.provider, + })); + if (!models.some((model) => modelKey(model) === modelKey(current))) { + choices.unshift({ + value: modelKey(current), + name: current.name || current.id, + description: current.provider, + }); + } + options.push({ + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: modelKey(current), + options: choices, + }); + } + const levels = session.getAvailableThinkingLevels(); + if (levels.length > 1) { + options.push({ + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: session.thinkingLevel, + options: levels.map((level) => ({ value: level, name: level })), + }); + } + return options; + } + + async setConfigOption(configId: string, value: unknown): Promise { + if (typeof value !== "string") throw new AcpError(AcpErrorCode.InvalidParams, "value must be a string"); + const session = this.session; + if (configId === "model") { + const model = this.selectableModels().find((candidate) => modelKey(candidate) === value); + if (!model) throw new AcpError(AcpErrorCode.InvalidParams, `Unknown model: ${value}`); + await session.setModel(model); + } else if (configId === "thinking") { + const level = session.getAvailableThinkingLevels().find((candidate) => candidate === value); + if (!level) throw new AcpError(AcpErrorCode.InvalidParams, `Unknown thinking level: ${value}`); + session.setThinkingLevel(level); + } else { + throw new AcpError(AcpErrorCode.InvalidParams, `Unknown config option: ${configId}`); + } + this.sendUsage(); + return this.configOptions(); + } + + // ========================================================================= + // Events + // ========================================================================= + + private onEvent(event: Parameters[0]>[0]): void { + switch (event.type) { + case "message_start": + if (event.message.role === "assistant") this.messageId = `msg_${randomUUID()}`; + break; + case "message_update": { + const update = event.assistantMessageEvent; + const messageId = this.messageId ?? `msg_${this.messageCount++}`; + if (update.type === "text_delta" && update.delta) { + this.send({ + sessionUpdate: "agent_message_chunk", + messageId, + content: { type: "text", text: update.delta }, + }); + } else if (update.type === "thinking_delta" && update.delta) { + this.send({ + sessionUpdate: "agent_thought_chunk", + messageId, + content: { type: "text", text: update.delta }, + }); + } + break; + } + case "tool_execution_start": { + if (event.parentToolCallId) break; + this.runningTools.set(event.toolCallId, { name: event.toolName, args: event.args }); + if (event.toolName === "edit" || event.toolName === "write") this.snapshot(event.toolCallId, event.args); + const info = toolCallInfo(event.toolName, event.args, this.cwd); + // Zed's extension: the command's output in a terminal view, which updates append to. + const terminal = event.toolName === "bash" && this.agent.capabilities._meta?.terminal_output === true; + if (terminal) this.terminals.set(event.toolCallId, TERMINAL_START); + this.send({ + sessionUpdate: "tool_call", + toolCallId: event.toolCallId, + name: event.toolName, + title: info.title, + kind: info.kind, + status: "in_progress", + locations: info.locations, + rawInput: event.args, + ...(terminal + ? { + content: [terminalContent(event.toolCallId)], + _meta: { terminal_info: { terminal_id: event.toolCallId, cwd: this.cwd } }, + } + : {}), + }); + break; + } + case "tool_execution_update": + if (!event.parentToolCallId && event.toolName === "bash") + this.liveBashOutput(event.toolCallId, event.partialResult); + break; + case "tool_execution_end": { + if (event.parentToolCallId) break; + this.runningTools.delete(event.toolCallId); + const live = this.liveOutput.get(event.toolCallId); + if (live) clearTimeout(live.timer); + if (this.terminals.has(event.toolCallId)) { + this.endTerminal(event.toolCallId, event.result, event.isError); + break; + } + this.liveOutput.delete(event.toolCallId); + this.send({ + sessionUpdate: "tool_call_update", + toolCallId: event.toolCallId, + status: event.isError ? "failed" : "completed", + content: this.resultContent(event.toolCallId, event.toolName, event.result, event.isError), + }); + break; + } + case "session_info_changed": + this.send({ sessionUpdate: "session_info_update", title: event.name ?? null }); + break; + case "thinking_level_changed": + this.send({ sessionUpdate: "config_option_update", configOptions: this.configOptions() }); + break; + case "compaction_end": + this.sendUsage(); + break; + case "auto_retry_start": + this.notice("warning", `Retrying (${event.attempt}/${event.maxAttempts})`, event.errorMessage); + break; + default: + break; + } + } + + private notice(severity: "info" | "warning" | "error", title: string, description?: string): void { + if (this.agent.capabilities.session?.notices) { + this.send({ sessionUpdate: "notice", severity, title, ...(description ? { description } : {}) }); + } else { + console.error(description ? `${title}: ${description}` : title); + } + } + + private snapshot(toolCallId: string, args: unknown): void { + const path = toolPath(args, this.cwd); + if (!path) return; + try { + if (!existsSync(path)) { + this.before.set(toolCallId, { path, text: null }); + } else if (statSync(path).size <= MAX_DIFF_BYTES) { + this.before.set(toolCallId, { path, text: readFileSync(path, "utf8") }); + } + } catch { + // No diff for files that cannot be read. + } + } + + private resultContent(toolCallId: string, toolName: string, result: unknown, isError: boolean): ToolCallContent[] { + const before = this.before.get(toolCallId); + this.before.delete(toolCallId); + if (before && !isError) { + try { + if (statSync(before.path).size <= MAX_DIFF_BYTES) { + const after = readFileSync(before.path, "utf8"); + if (after !== before.text) + return [{ type: "diff", path: before.path, oldText: before.text, newText: after }]; + } + } catch { + // Fall back to the tool's own result. + } + } + return toolName === "bash" ? bashResultContent(result) : toolResultContent(result); + } + + private liveBashOutput(toolCallId: string, partialResult: unknown): void { + let live = this.liveOutput.get(toolCallId); + if (!live) { + live = {}; + this.liveOutput.set(toolCallId, live); + } + if (this.terminals.has(toolCallId)) { + live.tail = bashOutputTail(partialResult); + } else { + const text = resultText(partialResult); + live.text = text.length > MAX_LIVE_OUTPUT_CHARS ? `…${text.slice(-MAX_LIVE_OUTPUT_CHARS)}` : text; + } + if (live.timer) return; + live.timer = setTimeout(() => { + const current = this.liveOutput.get(toolCallId); + if (!current) return; + current.timer = undefined; + if (this.terminals.has(toolCallId)) { + this.sendTerminalOutput(toolCallId); + return; + } + this.send({ + sessionUpdate: "tool_call_update", + toolCallId, + // (Some clients take an update without a status for a finished call.) + status: "in_progress", + content: current.text ? [textContent(fenced(current.text))] : [], + }); + }, LIVE_OUTPUT_INTERVAL_MS); + } + + /** + * Sends a terminal the output it has not been sent yet, and a last line. Without content, which would replace the + * terminal. + */ + private sendTerminalOutput(toolCallId: string, lastLine?: string): void { + let sent = this.terminals.get(toolCallId); + if (!sent) return; + let data = ""; + const tail = this.liveOutput.get(toolCallId)?.tail; + if (tail) { + const delta = terminalOutputDelta(sent, tail); + sent = delta.sent; + this.terminals.set(toolCallId, sent); + data = delta.data; + } + if (lastLine) data += `${sent.end > 0 && !sent.text.endsWith("\n") ? "\n" : ""}${lastLine}\n`; + if (data === "") return; + this.send({ + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + _meta: { terminal_output: { terminal_id: toolCallId, data } }, + }); + } + + /** Ends a command's terminal: the rest of its output, why it stopped when it did not finish, and its exit code. */ + private endTerminal(toolCallId: string, result: unknown, isError: boolean): void { + const exitCode = bashExitCode(result, isError); + let reason: string | undefined; + if (exitCode === null) { + // Timed out, aborted, or blocked: the result's text says why, after the output if there was any. + const text = resultText(result).trim(); + const hadOutput = (this.liveOutput.get(toolCallId)?.tail?.totalBytes ?? 0) > 0; + reason = hadOutput ? text.slice(text.lastIndexOf("\n\n") + 1).trim() : text; + } + this.sendTerminalOutput(toolCallId, reason); + this.liveOutput.delete(toolCallId); + this.terminals.delete(toolCallId); + this.send({ + sessionUpdate: "tool_call_update", + toolCallId, + status: isError ? "failed" : "completed", + _meta: { terminal_exit: { terminal_id: toolCallId, exit_code: exitCode } }, + }); + } + + // ========================================================================= + // Extension UI + // ========================================================================= + + /** A permission request about the running tool call, or about a dialog of its own when no tool runs. */ + private async askPermission( + title: string, + message: string | undefined, + options: PermissionOption[], + opts: ExtensionUIDialogOptions | undefined, + ): Promise { + if (opts?.signal?.aborted) return undefined; + const running = [...this.runningTools.keys()].at(-1); + const toolCallId = running ?? `dialog_${randomUUID()}`; + const content = message ? { content: [textContent(message)] } : {}; + const toolCall = running + ? { toolCallId, title, ...content } + : { toolCallId, title, kind: "other" as const, status: "pending" as const, ...content }; + const request = this.agent.connection.request("session/request_permission", { + sessionId: this.id, + toolCall, + options, + }); + const response = await settleWithin(request, opts); + const selected = response && response.outcome.outcome === "selected" ? response.outcome.optionId : undefined; + // Clients keep what a permission request changes on the tool call: give a running tool its own title and output + // back, and end a dialog's own entry. + const tool = running ? this.runningTools.get(running) : undefined; + if (tool) { + const live = this.liveOutput.get(toolCallId)?.text; + const output = this.terminals.has(toolCallId) + ? [terminalContent(toolCallId)] + : live + ? [textContent(fenced(live))] + : []; + this.send({ + sessionUpdate: "tool_call_update", + toolCallId, + title: toolCallInfo(tool.name, tool.args, this.cwd).title, + ...(message ? { content: output } : {}), + }); + } else if (!running) { + this.send({ + sessionUpdate: "tool_call_update", + toolCallId, + status: selected === undefined ? "failed" : "completed", + }); + } + return selected; + } + + private async askText( + title: string, + field: { description?: string; default?: string }, + opts: ExtensionUIDialogOptions | undefined, + ): Promise { + if (!this.agent.capabilities.elicitation?.form || opts?.signal?.aborted) return undefined; + const running = [...this.runningTools.keys()].at(-1); + const request = this.agent.connection.request("elicitation/create", { + sessionId: this.id, + ...(running ? { toolCallId: running } : {}), + mode: "form", + message: title, + requestedSchema: { + type: "object", + properties: { value: { type: "string", title, ...field } }, + required: ["value"], + }, + }); + const response = await settleWithin(request, opts); + const value = response?.action === "accept" ? response.content?.value : undefined; + return typeof value === "string" ? value : undefined; + } + + private createUIContext(): ExtensionUIContext { + return { + select: async (title, options, opts) => { + const permissionOptions: PermissionOption[] = options.map((option, index) => ({ + optionId: String(index), + name: option, + kind: /^(no|cancel|reject|deny|decline|abort|skip)\b/i.test(option) ? "reject_once" : "allow_once", + })); + const selected = await this.askPermission(title, undefined, permissionOptions, opts); + return selected === undefined ? undefined : options[Number(selected)]; + }, + confirm: async (title, message, opts) => + (await this.askPermission( + title, + message, + [ + { optionId: "yes", name: "Yes", kind: "allow_once" }, + { optionId: "no", name: "No", kind: "reject_once" }, + ], + opts, + )) === "yes", + input: (title, placeholder, opts) => + this.askText(title, placeholder ? { description: placeholder } : {}, opts), + editor: (title, prefill) => this.askText(title, prefill ? { default: prefill } : {}, undefined), + notify: (message, type) => this.notice(type ?? "info", message), + onTerminalInput: () => () => {}, + setStatus: () => {}, + setWorkingMessage: () => {}, + setWorkingVisible: () => {}, + setWorkingIndicator: () => {}, + setHiddenThinkingLabel: () => {}, + setWidget: () => {}, + setFooter: () => {}, + setHeader: () => {}, + setTitle: () => {}, + custom: async () => undefined as never, + pasteToEditor: () => {}, + setEditorText: () => {}, + getEditorText: () => "", + addAutocompleteProvider: () => {}, + setEditorComponent: () => {}, + getEditorComponent: () => undefined, + get theme() { + return theme; + }, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: () => ({ success: false, error: "Themes do not apply in ACP mode" }), + getToolsExpanded: () => false, + setToolsExpanded: () => {}, + }; + } +} + +/** The client's answer, or undefined when the dialog's signal aborts or its timeout passes first. */ +async function settleWithin( + request: Promise, + opts: ExtensionUIDialogOptions | undefined, +): Promise { + const guarded = request.catch(() => undefined); + if (!opts?.signal && !opts?.timeout) return guarded; + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const finish = (value: T | undefined) => { + clearTimeout(timer); + opts.signal?.removeEventListener("abort", onAbort); + resolve(value); + }; + const onAbort = () => finish(undefined); + opts.signal?.addEventListener("abort", onAbort, { once: true }); + if (opts.timeout) timer = setTimeout(() => finish(undefined), opts.timeout); + void guarded.then(finish); + }); +} + +function textContent(text: string): ToolCallContent { + return { type: "content", content: { type: "text", text } }; +} + +/** A command's terminal, which has the tool call's id. */ +function terminalContent(toolCallId: string): ToolCallContent { + return { type: "terminal", terminalId: toolCallId }; +} + +/** How to set up a model, which ACP mode cannot do itself. */ +function setupHelp(): string { + return [ + `Run \`${COMMAND_NAME}\` in a terminal and use /login, or give the agent a provider's API key in its environment`, + `(\`env\` in the editor's agent settings). See:\n ${join(getDocsPath(), "providers.md")}`, + ].join(" "); +} + +function modelKey(model: Pick, "provider" | "id">): string { + return `${model.provider}/${model.id}`; +} + +function requireString(params: Record, key: string): string { + const value = params[key]; + if (typeof value !== "string" || value === "") throw new AcpError(AcpErrorCode.InvalidParams, `${key} is required`); + return value; +} + +/** The session's working directory, which must be an existing absolute path. */ +function requireDirectory(params: Record): string { + const cwd = requireString(params, "cwd"); + let isDirectory = false; + try { + isDirectory = isAbsolute(cwd) && statSync(cwd).isDirectory(); + } catch { + // Reported below. + } + if (!isDirectory) + throw new AcpError(AcpErrorCode.InvalidParams, `cwd is not an absolute path of a directory: ${cwd}`); + return cwd; +} + +function mcpServersOf(params: Record): McpServer[] { + return Array.isArray(params.mcpServers) ? (params.mcpServers as unknown[]).filter(isMcpServer) : []; +} + +function isMcpServer(value: unknown): value is McpServer { + if (!isRecord(value) || typeof value.name !== "string") return false; + if (value.type === "http" || value.type === "sse") + return typeof value.url === "string" && Array.isArray(value.headers); + return typeof value.command === "string" && Array.isArray(value.args); +} + +function sessionInfo(info: PiSessionInfo): SessionInfo { + const title = info.name || info.firstMessage.replace(/\s+/g, " ").trim().slice(0, 100) || null; + return { sessionId: info.id, cwd: info.cwd, title, updatedAt: info.modified.toISOString() }; +} + +/** A session of any directory, for `session/load` of a session listed without a directory filter. */ +async function findSessionFile(id: string, sessionDir: string | undefined): Promise { + return (await SessionManager.listAll(sessionDir)).find((info) => info.id === id)?.path; +} diff --git a/packages/coding-agent/src/extensions/acp/translate.ts b/packages/coding-agent/src/extensions/acp/translate.ts new file mode 100644 index 000000000..5218e4cd9 --- /dev/null +++ b/packages/coding-agent/src/extensions/acp/translate.ts @@ -0,0 +1,393 @@ +/** + * Conversions between ACP and Pi: prompts in, tool calls and conversation history out. Pure functions, no I/O. + */ + +import { isAbsolute, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { ImageContent } from "@earendil-works/pi-ai"; +import { resolveToCwd } from "../../core/tools/path-utils.ts"; +import { isRecord } from "./connection.ts"; +import type { + ContentBlock, + SessionUpdate, + StopReason, + ToolCallContent, + ToolCallLocation, + ToolCallStatus, + ToolKind, +} from "./types.ts"; + +/** A prompt as `AgentSession.prompt()` takes it. */ +export interface PiPrompt { + text: string; + images: ImageContent[]; +} + +/** The path of a `file:` URI, or undefined for other URIs. */ +export function fileUriPath(uri: string): string | undefined { + if (!uri.startsWith("file:")) return undefined; + try { + return fileURLToPath(uri); + } catch { + return undefined; + } +} + +/** + * Turns ACP prompt content into Pi's prompt text and images. Text and links stay where the user put them (a link reads + * as its path, as an `@` mention does in Pi's editor); embedded resources follow as `` blocks, the form Pi gives + * `@file` arguments. + */ +export function promptToPi(blocks: readonly ContentBlock[]): PiPrompt { + let text = ""; + const files: string[] = []; + const images: ImageContent[] = []; + const append = (piece: string) => { + if (text.length > 0 && !/\s$/.test(text) && !/^\s/.test(piece)) text += " "; + text += piece; + }; + for (const block of blocks) { + switch (block.type) { + case "text": + text += block.text; + break; + case "image": + images.push({ type: "image", data: block.data, mimeType: block.mimeType }); + break; + case "resource_link": + append(fileUriPath(block.uri) ?? block.uri); + break; + case "resource": { + const resource = block.resource; + const name = fileUriPath(resource.uri) ?? resource.uri; + if ("text" in resource) { + files.push(`\n${resource.text}\n`); + } else if (resource.mimeType?.startsWith("image/")) { + images.push({ type: "image", data: resource.blob, mimeType: resource.mimeType }); + } else { + files.push(``); + } + break; + } + case "audio": + // Not advertised (promptCapabilities.audio is false); a client that sends it anyway loses only that block. + break; + } + } + if (files.length > 0) text = text.length > 0 ? `${files.join("\n")}\n${text}` : files.join("\n"); + return { text, images }; +} + +/** How a tool call is shown in the editor. */ +export interface ToolCallInfo { + title: string; + kind: ToolKind; + locations: ToolCallLocation[]; +} + +function stringArg(args: unknown, key: string): string | undefined { + if (typeof args !== "object" || args === null) return undefined; + const value = (args as Record)[key]; + return typeof value === "string" ? value : undefined; +} + +function numberArg(args: unknown, key: string): number | undefined { + if (typeof args !== "object" || args === null) return undefined; + const value = (args as Record)[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function oneLine(text: string, max = 120): string { + const line = text.replace(/\s+/g, " ").trim(); + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +} + +/** Absolute path of a tool's path argument, resolved as the tools resolve it. */ +export function toolPath(args: unknown, cwd: string, key = "path"): string | undefined { + const path = stringArg(args, key); + if (path === undefined || path === "") return undefined; + const absolute = resolveToCwd(path, cwd); + return isAbsolute(absolute) ? absolute : resolve(cwd, absolute); +} + +/** Title, kind, and file locations of one of Pi's tools; other tools are `other`, titled with their name. */ +export function toolCallInfo(toolName: string, args: unknown, cwd: string): ToolCallInfo { + const path = toolPath(args, cwd); + const at = path ? [{ path }] : []; + switch (toolName) { + case "read": { + const offset = numberArg(args, "offset"); + return { + title: `Read ${stringArg(args, "path") ?? "file"}`, + kind: "read", + // The read tool's offset counts from 1; a location's line counts from 0, as Zed takes it. + locations: path + ? [{ path, line: offset !== undefined && offset >= 1 ? Math.floor(offset) - 1 : undefined }] + : [], + }; + } + case "edit": + return { title: `Edit ${stringArg(args, "path") ?? "file"}`, kind: "edit", locations: at }; + case "write": + return { title: `Write ${stringArg(args, "path") ?? "file"}`, kind: "edit", locations: at }; + case "bash": { + const command = stringArg(args, "command"); + return { title: command ? oneLine(command) : "Run command", kind: "execute", locations: [] }; + } + case "grep": { + const pattern = stringArg(args, "pattern"); + return { + title: pattern ? `Search ${oneLine(pattern, 80)}${path ? ` in ${stringArg(args, "path")}` : ""}` : "Search", + kind: "search", + locations: at, + }; + } + case "find": { + const pattern = stringArg(args, "pattern"); + return { title: pattern ? `Find ${oneLine(pattern, 80)}` : "Find files", kind: "search", locations: at }; + } + case "ls": + return { title: `List ${stringArg(args, "path") ?? "."}`, kind: "search", locations: at }; + default: + return { title: toolName, kind: "other", locations: at }; + } +} + +/** The text and images of a tool result (`AgentToolResult.content` or a tool result message's content). */ +export function toolResultContent(result: unknown): ToolCallContent[] { + const content = + typeof result === "object" && result !== null && Array.isArray((result as { content?: unknown }).content) + ? ((result as { content: unknown[] }).content as unknown[]) + : Array.isArray(result) + ? result + : undefined; + if (!content) return []; + const blocks: ToolCallContent[] = []; + for (const item of content) { + if (typeof item !== "object" || item === null) continue; + const part = item as Record; + if (part.type === "text" && typeof part.text === "string") { + if (part.text.length > 0) blocks.push({ type: "content", content: { type: "text", text: part.text } }); + } else if (part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") { + blocks.push({ type: "content", content: { type: "image", data: part.data, mimeType: part.mimeType } }); + } + } + return blocks; +} + +/** Shows text as a fenced block, for command output, which editors would otherwise render as Markdown. */ +export function fenced(text: string): string { + let fence = "```"; + while (text.includes(fence)) fence += "`"; + return `${fence}\n${text}${text.endsWith("\n") ? "" : "\n"}${fence}`; +} + +/** Bash output in a fence, so the editor shows it as it is. */ +export function bashResultContent(result: unknown): ToolCallContent[] { + return toolResultContent(result).map((block) => + block.type === "content" && block.content.type === "text" + ? { type: "content", content: { type: "text", text: fenced(block.content.text) } } + : block, + ); +} + +/** The text of a tool result, its text blocks joined. */ +export function resultText(result: unknown): string { + return toolResultContent(result) + .map((block) => (block.type === "content" && block.content.type === "text" ? block.content.text : "")) + .join(""); +} + +/** Command output as the bash tool reports it while the command runs. */ +export interface OutputTail { + /** The output so far, or its end when `truncated` (which may leave out a trailing newline). */ + text: string; + /** UTF-8 bytes of all the output so far. */ + totalBytes: number; + truncated: boolean; +} + +/** The output in a partial result of the bash tool. */ +export function bashOutputTail(partialResult: unknown): OutputTail { + const text = resultText(partialResult); + const details = isRecord(partialResult) ? partialResult.details : undefined; + const truncation = isRecord(details) ? details.truncation : undefined; + if (isRecord(truncation) && truncation.truncated === true && typeof truncation.totalBytes === "number") { + // Whole lines, which a newline comes before: with it, a tail that has only new lines still shows where they start. + const lines = truncation.lastLinePartial !== true; + return { text: lines ? `\n${text}` : text, totalBytes: truncation.totalBytes, truncated: true }; + } + return { text, totalBytes: Buffer.byteLength(text, "utf8"), truncated: false }; +} + +/** What a terminal has been sent: the output up to byte `end`, which `text` is the end of. */ +export interface TerminalSent { + text: string; + end: number; + /** False when `text` may end a byte before `end` (a truncated tail without its trailing newline). */ + exact: boolean; +} + +export const TERMINAL_START: TerminalSent = { text: "", end: 0, exact: true }; + +/** Clears a terminal's screen and scrollback. */ +const CLEAR_TERMINAL = "\x1b[H\x1b[2J\x1b[3J"; + +/** + * The part of the output a terminal has not been sent yet, for a terminal that appends what it is sent. The bash tool + * reports only the end of long output, so the new part is found by byte counts, and checked against what was sent: + * the rest of the new tail must be the end of the old one. A truncated tail leaves out a trailing newline, so where a + * tail ends is known only to a byte; the counts that could be right are tried in turn. Output that went by between two + * reports is marked as skipped; output that changed instead of growing (another tool's partial results) clears the + * terminal and starts over. + */ +export function terminalOutputDelta(sent: TerminalSent, next: OutputTail): { data: string; sent: TerminalSent } { + const bytes = Buffer.from(next.text, "utf8"); + const added = next.totalBytes - sent.end; + const lengths = [added, ...(next.truncated ? [added - 1] : []), ...(sent.exact ? [] : [added + 1])]; + for (const length of lengths) { + if (length < 0 || length > bytes.length) continue; + const at = bytes.length - length; + if (!sent.text.endsWith(bytes.subarray(0, at).toString("utf8"))) continue; + return { + data: bytes.subarray(at).toString("utf8"), + sent: { text: next.text, end: sent.end + length, exact: true }, + }; + } + const restart = { text: next.text, end: next.totalBytes, exact: !next.truncated }; + if (lengths.every((length) => length > bytes.length)) { + const before = sent.text !== "" && !sent.text.endsWith("\n") ? "\n" : ""; + const after = next.text.startsWith("\n") ? "" : "\n"; + return { data: `${before}[… output skipped …]${after}${next.text}`, sent: restart }; + } + return { data: `${CLEAR_TERMINAL}${next.text}`, sent: restart }; +} + +/** The exit code of a finished bash call, when the command ran to its end. */ +export function bashExitCode(result: unknown, isError: boolean): number | null { + const structured = isRecord(result) ? result.structuredContent : undefined; + if (isRecord(structured) && typeof structured.exit_code === "number") return structured.exit_code; + return isError ? null : 0; +} + +/** The prompt's stop reason, from the last assistant message of the run. */ +export function stopReasonOf(messages: readonly AgentMessage[], cancelled: boolean): StopReason { + if (cancelled) return "cancelled"; + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role !== "assistant") continue; + switch (message.stopReason) { + case "aborted": + return "cancelled"; + case "length": + return "max_tokens"; + default: + return "end_turn"; + } + } + return "end_turn"; +} + +/** The error that ended the run, if the last assistant message failed. */ +export function runError(messages: readonly AgentMessage[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role !== "assistant") continue; + return message.stopReason === "error" ? message.errorMessage || "The model request failed" : undefined; + } + return undefined; +} + +function textOf(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => + typeof part === "object" && part !== null && (part as { type?: unknown }).type === "text" + ? String((part as { text?: unknown }).text ?? "") + : "", + ) + .join(""); +} + +function imagesOf(content: unknown): ContentBlock[] { + if (!Array.isArray(content)) return []; + const images: ContentBlock[] = []; + for (const part of content) { + if (typeof part !== "object" || part === null) continue; + const image = part as { type?: unknown; data?: unknown; mimeType?: unknown }; + if (image.type === "image" && typeof image.data === "string" && typeof image.mimeType === "string") { + images.push({ type: "image", data: image.data, mimeType: image.mimeType }); + } + } + return images; +} + +/** + * The conversation as session updates, for `session/load`: user and assistant messages, thinking, and tool calls with + * their results. Messages Pi keeps out of the transcript (extension state, summaries) are left out. + */ +export function replayMessages(messages: readonly AgentMessage[], cwd: string): SessionUpdate[] { + const updates: SessionUpdate[] = []; + const results = new Map(); + for (const message of messages) { + if (message.role === "toolResult") { + results.set(message.toolCallId, { content: message.content, isError: message.isError }); + } + } + let index = 0; + for (const message of messages) { + const messageId = `msg_${index++}`; + switch (message.role) { + case "user": { + const text = textOf(message.content); + if (text) updates.push({ sessionUpdate: "user_message_chunk", messageId, content: { type: "text", text } }); + for (const image of imagesOf(message.content)) { + updates.push({ sessionUpdate: "user_message_chunk", messageId, content: image }); + } + break; + } + case "assistant": { + for (const part of message.content) { + if (part.type === "thinking" && part.thinking) { + updates.push({ + sessionUpdate: "agent_thought_chunk", + messageId, + content: { type: "text", text: part.thinking }, + }); + } else if (part.type === "text" && part.text) { + updates.push({ + sessionUpdate: "agent_message_chunk", + messageId, + content: { type: "text", text: part.text }, + }); + } else if (part.type === "toolCall") { + const info = toolCallInfo(part.name, part.arguments, cwd); + const result = results.get(part.id); + const status: ToolCallStatus = result ? (result.isError ? "failed" : "completed") : "failed"; + updates.push({ + sessionUpdate: "tool_call", + toolCallId: part.id, + name: part.name, + title: info.title, + kind: info.kind, + status, + locations: info.locations, + rawInput: part.arguments, + content: result + ? part.name === "bash" + ? bashResultContent(result.content) + : toolResultContent(result.content) + : [], + }); + } + } + break; + } + default: + break; + } + } + return updates; +} diff --git a/packages/coding-agent/src/extensions/acp/types.ts b/packages/coding-agent/src/extensions/acp/types.ts new file mode 100644 index 000000000..a874f1719 --- /dev/null +++ b/packages/coding-agent/src/extensions/acp/types.ts @@ -0,0 +1,246 @@ +/** + * The part of the Agent Client Protocol (version 1, https://agentclientprotocol.com/protocol/v1/schema) that the agent side + * here sends and receives. Fields the agent ignores are left out; unknown fields from clients are tolerated. + */ + +export const ACP_PROTOCOL_VERSION = 1; + +/** JSON-RPC error codes ACP uses. */ +export const AcpErrorCode = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, + AuthRequired: -32000, + ResourceNotFound: -32002, +} as const; + +export interface Implementation { + name: string; + title?: string; + version?: string; +} + +export interface ClientCapabilities { + fs?: { readTextFile?: boolean; writeTextFile?: boolean } | null; + terminal?: boolean | null; + elicitation?: { form?: object | null; url?: object | null } | null; + session?: { + notices?: object | null; + compaction?: object | null; + configOptions?: { boolean?: object | null } | null; + } | null; + /** Extensions: Zed's `terminal_output: true` takes command output as a terminal (see `ToolCallUpdateFields._meta`). */ + _meta?: Record | null; +} + +export interface InitializeRequest { + protocolVersion: number; + clientCapabilities?: ClientCapabilities; + clientInfo?: Implementation; +} + +export interface InitializeResponse { + protocolVersion: number; + agentCapabilities: { + loadSession: boolean; + promptCapabilities: { image: boolean; audio: boolean; embeddedContext: boolean }; + mcpCapabilities: { http: boolean; sse: boolean }; + sessionCapabilities: { list: object; resume: object; close: object }; + }; + agentInfo: Implementation; + authMethods: AuthMethod[]; +} + +export interface AuthMethod { + id: string; + name: string; + description?: string; +} + +export interface EnvVariable { + name: string; + value: string; +} + +export interface HttpHeader { + name: string; + value: string; +} + +export type McpServer = + | { type?: "stdio"; name: string; command: string; args: string[]; env?: EnvVariable[] } + | { type: "http"; name: string; url: string; headers: HttpHeader[] } + | { type: "sse"; name: string; url: string; headers: HttpHeader[] }; + +export interface NewSessionRequest { + cwd: string; + mcpServers?: McpServer[]; +} + +export interface LoadSessionRequest { + sessionId: string; + cwd: string; + mcpServers?: McpServer[]; +} + +export interface ListSessionsRequest { + cwd?: string | null; + cursor?: string | null; +} + +export interface SessionInfo { + sessionId: string; + cwd: string; + title?: string | null; + updatedAt?: string | null; +} + +export interface Annotations { + audience?: string[]; + priority?: number; + lastModified?: string; +} + +export type ContentBlock = + | { type: "text"; text: string; annotations?: Annotations } + | { type: "image"; data: string; mimeType: string; uri?: string | null; annotations?: Annotations } + | { type: "audio"; data: string; mimeType: string; annotations?: Annotations } + | { + type: "resource_link"; + uri: string; + name: string; + title?: string | null; + description?: string | null; + mimeType?: string | null; + size?: number | null; + annotations?: Annotations; + } + | { + type: "resource"; + resource: + | { uri: string; text: string; mimeType?: string | null } + | { uri: string; blob: string; mimeType?: string | null }; + annotations?: Annotations; + }; + +export interface PromptRequest { + sessionId: string; + prompt: ContentBlock[]; +} + +export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled"; + +export type ToolKind = + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; + +export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed"; + +export type ToolCallContent = + | { type: "content"; content: ContentBlock } + | { type: "diff"; path: string; oldText?: string | null; newText: string } + | { type: "terminal"; terminalId: string }; + +export interface ToolCallLocation { + path: string; + line?: number | null; +} + +export interface ToolCallUpdateFields { + toolCallId: string; + name?: string; + title?: string; + kind?: ToolKind; + status?: ToolCallStatus; + content?: ToolCallContent[]; + locations?: ToolCallLocation[]; + rawInput?: unknown; + rawOutput?: unknown; + /** + * Extensions. For clients with `terminal_output`: `terminal_info` (`terminal_id`, `cwd`) on a tool call shows a + * terminal, `terminal_output` (`terminal_id`, `data`) on updates appends to it, and `terminal_exit` (`terminal_id`, + * `exit_code`, `signal`) ends it. + */ + _meta?: Record; +} + +export interface AvailableCommand { + name: string; + description: string; + input?: { hint: string } | null; +} + +export interface ConfigOptionValue { + value: string; + name: string; + description?: string | null; +} + +export interface ConfigOption { + id: string; + name: string; + description?: string | null; + category?: "mode" | "model" | "model_config" | "thought_level"; + type: "select"; + currentValue: string; + options: ConfigOptionValue[]; +} + +export type SessionUpdate = + | { sessionUpdate: "user_message_chunk"; content: ContentBlock; messageId?: string } + | { sessionUpdate: "agent_message_chunk"; content: ContentBlock; messageId?: string } + | { sessionUpdate: "agent_thought_chunk"; content: ContentBlock; messageId?: string } + | ({ sessionUpdate: "tool_call"; title: string } & ToolCallUpdateFields) + | ({ sessionUpdate: "tool_call_update" } & ToolCallUpdateFields) + | { sessionUpdate: "available_commands_update"; availableCommands: AvailableCommand[] } + | { sessionUpdate: "config_option_update"; configOptions: ConfigOption[] } + | { sessionUpdate: "session_info_update"; title?: string | null; updatedAt?: string | null } + | { sessionUpdate: "usage_update"; used: number; size: number; cost?: { amount: number; currency: string } | null } + | { sessionUpdate: "notice"; severity: "info" | "warning" | "error"; title: string; description?: string | null }; + +export interface SessionNotification { + sessionId: string; + update: SessionUpdate; +} + +export interface PermissionOption { + optionId: string; + name: string; + kind: "allow_once" | "allow_always" | "reject_once" | "reject_always"; +} + +export interface RequestPermissionRequest { + sessionId: string; + toolCall: ToolCallUpdateFields; + options: PermissionOption[]; +} + +export interface RequestPermissionResponse { + outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string }; +} + +export interface CreateElicitationRequest { + sessionId: string; + mode: "form"; + message: string; + requestedSchema: { + type: "object"; + properties: Record; + required?: string[]; + }; +} + +export interface CreateElicitationResponse { + action: "accept" | "decline" | "cancel"; + content?: Record | null; +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index d16ec2b54..4aeb4128e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -61,6 +61,7 @@ import { collectSettingsDiagnostics, deduplicateDiagnostics } from "./core/setti import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; +import { loadAcpMode } from "./extensions/acp/mode.lazy.ts"; import { quietOtherHerdrReporters } from "./extensions/herdr/index.ts"; import { builtInExtensions } from "./extensions/index.ts"; import { loadMcpCommand } from "./extensions/mcp/cli.lazy.ts"; @@ -112,7 +113,8 @@ function isTruthyEnvFlag(value: string | undefined): boolean { } function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode { - if (parsed.mode === "rpc") { + // ACP runs headless as RPC does (stdin and stdout carry the protocol); it differs only in the loop that serves it. + if (parsed.mode === "rpc" || parsed.mode === "acp") { return "rpc"; } if (parsed.mode === "json") { @@ -124,7 +126,7 @@ function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean) return "interactive"; } -function toPrintOutputMode(appMode: AppMode): Exclude { +function toPrintOutputMode(appMode: AppMode): Exclude { return appMode === "json" ? "json" : "text"; } @@ -666,8 +668,8 @@ export async function main(args: string[], options?: MainOptions) { takeOverStdout(); } - if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { - console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); + if ((parsed.mode === "rpc" || parsed.mode === "acp") && parsed.fileArgs.length > 0) { + console.error(chalk.red(`Error: @file arguments are not supported in ${parsed.mode.toUpperCase()} mode`)); process.exit(1); } @@ -716,10 +718,10 @@ export async function main(args: string[], options?: MainOptions) { // the target session cwd is known. The startup-cwd settings manager is used only for // sessionDir lookup during session selection. const envSessionDir = process.env[ENV_SESSION_DIR]; - const sessionDir = + const explicitSessionDir = (parsed.sessionDir ? normalizePath(parsed.sessionDir) : undefined) ?? - (envSessionDir ? expandTildePath(envSessionDir) : undefined) ?? - startupSettingsManager.getSessionDir(); + (envSessionDir ? expandTildePath(envSessionDir) : undefined); + const sessionDir = explicitSessionDir ?? startupSettingsManager.getSessionDir(); let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager); const missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd); if (missingSessionCwdIssue) { @@ -770,6 +772,9 @@ export async function main(args: string[], options?: MainOptions) { const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates); const resolvedThemePaths = resolveCliPaths(cwd, parsed.themes); + // Loaded only for `--acp`: other modes never load the ACP code. + const acp = parsed.mode === "acp" ? await loadAcpMode() : undefined; + const runtimeExtensionFactories = acp ? [...extensionFactories, acp.acpExtension] : extensionFactories; const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, agentDir, @@ -834,7 +839,7 @@ export async function main(args: string[], options?: MainOptions) { noContextFiles: parsed.noContextFiles, systemPrompt: parsed.systemPrompt, appendSystemPrompt: parsed.appendSystemPrompt, - extensionFactories, + extensionFactories: runtimeExtensionFactories, }, }); const { settingsManager, modelRuntime, resourceLoader } = services; @@ -906,6 +911,11 @@ export async function main(args: string[], options?: MainOptions) { }; }; time("createRuntime"); + if (acp) { + // The editor chooses each session's directory, so ACP creates its runtimes itself, one per session. + await acp.runAcpMode({ createRuntime, agentDir, sessionDir: explicitSessionDir, offline: offlineMode }); + return; + } const runtime = await createAgentSessionRuntime(createRuntime, { cwd: sessionManager.getCwd(), agentDir, diff --git a/packages/coding-agent/test/acp-mode.test.ts b/packages/coding-agent/test/acp-mode.test.ts new file mode 100644 index 000000000..a14271c8c --- /dev/null +++ b/packages/coding-agent/test/acp-mode.test.ts @@ -0,0 +1,646 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { ENV_AGENT_DIR } from "../src/config.ts"; +import type { + ConfigOption, + ContentBlock, + InitializeResponse, + RequestPermissionRequest, + SessionInfo, + SessionNotification, + SessionUpdate, + StopReason, + ToolCallContent, +} from "../src/extensions/acp/types.ts"; + +const cliPath = resolve(__dirname, "../src/cli.ts"); +// --import takes a module specifier, not a filesystem path. +const sourceResolverUrl = pathToFileURL(resolve(__dirname, "../src/experimental/source-resolver.ts")).href; +/** A built executable to test instead of the sources, such as Pi-Bolt's `out/pi-bolt/pi`. */ +const executable = process.env.PI_ACP_TEST_EXECUTABLE; + +// ============================================================================ +// A fake OpenAI-compatible model: the last user message picks a script, one step per model turn. +// ============================================================================ + +type Step = { text?: string; tool?: { name: string; args: Record }; hang?: boolean }; + +const SCRIPTS: Record = { + tools: [ + { text: "Writing. ", tool: { name: "write", args: { path: "notes.txt", content: "alpha\nbeta\n" } } }, + { tool: { name: "edit", args: { path: "notes.txt", edits: [{ oldText: "beta", newText: "gamma" }] } } }, + { tool: { name: "bash", args: { command: "echo from-bash" } } }, + { text: "All done." }, + ], + hello: [{ text: "Hello from the fake model." }], + live: [{ tool: { name: "bash", args: { command: "echo first; sleep 1; echo second" } } }, { text: "Ran it." }], + terminal: [ + { + tool: { + name: "bash", + args: { command: "echo first; sleep 1; printf '\\033[31msecond\\033[0m\\nno newline'; exit 3" }, + }, + }, + { text: "Ran it." }, + ], + timeout: [{ tool: { name: "bash", args: { command: "echo started; sleep 5", timeout: 1 } } }, { text: "Ran it." }], + slow: [{ hang: true }], +}; + +function sse(delta: Record, finish: string | null = null): string { + return `data: ${JSON.stringify({ + id: "fake", + object: "chat.completion.chunk", + model: "fake-model", + choices: [{ index: 0, delta, finish_reason: finish }], + })}\n\n`; +} + +function startFakeModel(): Promise { + const server = createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + if (req.method !== "POST") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ object: "list", data: [{ id: "fake-model", object: "model" }] })); + return; + } + const request = JSON.parse(body || "{}") as { messages?: Array<{ role: string; content: unknown }> }; + let turn = 0; + let prompt = ""; + for (const message of request.messages ?? []) { + if (message.role === "user") { + turn = 0; + prompt = typeof message.content === "string" ? message.content : JSON.stringify(message.content); + } else if (message.role === "tool") { + turn++; + } + } + const script = SCRIPTS[Object.keys(SCRIPTS).find((name) => prompt.includes(`[${name}]`)) ?? "hello"]; + const step = script[Math.min(turn, script.length - 1)]; + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write(sse({ role: "assistant", content: "" })); + if (step.hang) return; // Never answers: the client cancels. + if (step.text) res.write(sse({ content: step.text })); + if (step.tool) { + res.write( + sse({ + tool_calls: [ + { + index: 0, + id: `call_${turn}`, + type: "function", + function: { name: step.tool.name, arguments: JSON.stringify(step.tool.args) }, + }, + ], + }), + ); + res.write(sse({}, "tool_calls")); + } else { + res.write(sse({}, "stop")); + } + res.end("data: [DONE]\n\n"); + }); + }); + return new Promise((resolvePromise) => server.listen(0, "127.0.0.1", () => resolvePromise(server))); +} + +// ============================================================================ +// A minimal ACP client over the child's stdio +// ============================================================================ + +type Message = { + jsonrpc: "2.0"; + id?: number; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string }; +}; + +type UpdateOf = Extract; +type SessionResponse = { sessionId: string; configOptions: ConfigOption[] }; +type PromptResponse = { stopReason: StopReason }; + +function blockText(block: ContentBlock): string { + return block.type === "text" ? block.text : ""; +} + +function toolText(content: ToolCallContent[] | undefined): string { + const first = content?.[0]; + return first?.type === "content" ? blockText(first.content) : ""; +} + +function commandNames(client: AcpTestClient): string[] { + return client.updatesOf("available_commands_update")[0].availableCommands.map((command) => command.name); +} + +class AcpTestClient { + readonly child: ChildProcessWithoutNullStreams; + readonly updates: SessionNotification[] = []; + readonly clientRequests: Message[] = []; + stderr = ""; + private buffer = ""; + private nextId = 1; + private readonly pending = new Map void }>(); + /** Answers the agent's requests (permission, elicitation). */ + onRequest: (message: Message) => unknown = () => ({ outcome: { outcome: "cancelled" } }); + + constructor(cwd: string, agentDir: string, args: string[] = []) { + const command = executable + ? { file: executable, args: ["--acp", ...args] } + : { file: process.execPath, args: ["--import", sourceResolverUrl, cliPath, "--acp", ...args] }; + this.child = spawn(command.file, command.args, { + cwd, + env: { ...process.env, [ENV_AGENT_DIR]: agentDir, PI_OFFLINE: "1" }, + stdio: ["pipe", "pipe", "pipe"], + }); + this.child.stdout.setEncoding("utf8"); + this.child.stdout.on("data", (chunk: string) => { + this.buffer += chunk; + for (let index = this.buffer.indexOf("\n"); index !== -1; index = this.buffer.indexOf("\n")) { + const line = this.buffer.slice(0, index); + this.buffer = this.buffer.slice(index + 1); + if (line.trim()) this.handle(JSON.parse(line) as Message); + } + }); + this.child.stderr.on("data", (chunk) => { + this.stderr += chunk.toString(); + }); + } + + private handle(message: Message): void { + if (message.method && message.id !== undefined) { + this.clientRequests.push(message); + const result = this.onRequest(message); + this.write({ jsonrpc: "2.0", id: message.id, result }); + } else if (message.method === "session/update") { + this.updates.push(message.params as SessionNotification); + } else if (message.id !== undefined) { + this.pending.get(message.id)?.resolve(message); + this.pending.delete(message.id); + } + } + + private write(message: Message): void { + this.child.stdin.write(`${JSON.stringify(message)}\n`); + } + + call(method: string, params: unknown): Promise { + const id = this.nextId++; + return new Promise((resolvePromise) => { + this.pending.set(id, { resolve: resolvePromise }); + this.write({ jsonrpc: "2.0", id, method, params }); + }); + } + + async request(method: string, params: unknown): Promise { + const response = await this.call(method, params); + if (response.error) throw new Error(`${method}: ${JSON.stringify(response.error)}\n${this.stderr}`); + return response.result as T; + } + + notify(method: string, params: unknown): void { + this.write({ jsonrpc: "2.0", method, params }); + } + + async waitFor(predicate: () => boolean, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) throw new Error(`timed out\n${this.stderr}`); + await new Promise((r) => setTimeout(r, 20)); + } + } + + updatesOf(kind: K): UpdateOf[] { + return this.updates + .map((notification) => notification.update) + .filter((update): update is UpdateOf => update.sessionUpdate === kind); + } + + async stop(): Promise { + this.child.stdin.end(); + return new Promise((resolvePromise) => { + if (this.child.exitCode !== null) resolvePromise(this.child.exitCode); + else this.child.once("exit", (code) => resolvePromise(code)); + }); + } +} + +// biome-ignore lint/suspicious/noTemplateCurlyInString: a value from the editor, which must reach the server unexpanded +const EDITOR_TOKEN = "!a$b${HOME}"; + +const CLIENT_CAPABILITIES = { session: { notices: {} }, elicitation: { form: {} } }; + +describe("ACP mode", () => { + let server: Server; + let root: string; + let agentDir: string; + let projectDir: string; + const clients: AcpTestClient[] = []; + + const startClient = async (args: string[] = [], clientCapabilities: object = CLIENT_CAPABILITIES) => { + const client = new AcpTestClient(projectDir, agentDir, args); + clients.push(client); + const init = await client.request("initialize", { + protocolVersion: 1, + clientCapabilities, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + return { client, init }; + }; + + beforeAll(async () => { + server = await startFakeModel(); + const { port } = server.address() as AddressInfo; + root = mkdtempSync(join(tmpdir(), "pi-acp-")); + agentDir = join(root, "agent"); + projectDir = join(root, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + const models = [ + { id: "fake-model", contextWindow: 200000, maxTokens: 8192 }, + { id: "other-model", contextWindow: 200000, maxTokens: 8192 }, + ]; + writeFileSync( + join(agentDir, "models.json"), + JSON.stringify({ + providers: { + fake: { baseUrl: `http://127.0.0.1:${port}/v1`, api: "openai-completions", apiKey: "fake", models }, + }, + }), + ); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ lastChangelogVersion: "9999.0.0", defaultProvider: "fake", defaultModel: "fake-model" }), + ); + writeFileSync(join(agentDir, "auth.json"), "{}"); + }); + + afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.stop())); + }); + + afterAll(() => { + server?.close(); + rmSync(root, { recursive: true, force: true }); + }); + + it("initializes with Pi's capabilities", async () => { + const { init } = await startClient(); + expect(init.protocolVersion).toBe(1); + expect(init.agentCapabilities).toMatchObject({ + loadSession: true, + promptCapabilities: { image: true, embeddedContext: true }, + mcpCapabilities: { http: true }, + sessionCapabilities: { list: {}, resume: {}, close: {} }, + }); + expect(init.authMethods).toEqual([]); + }); + + it("runs a prompt: text, tool calls with diffs and output, and the stop reason", async () => { + const { client } = await startClient(); + const { sessionId, configOptions } = await client.request("session/new", { + cwd: projectDir, + mcpServers: [], + }); + expect(typeof sessionId).toBe("string"); + expect(configOptions.find((option) => option.id === "model")).toMatchObject({ + category: "model", + currentValue: "fake/fake-model", + }); + await client.waitFor(() => client.updatesOf("available_commands_update").length > 0); + expect(commandNames(client)).toContain("compact"); + + const result = await client.request("session/prompt", { + sessionId, + prompt: [{ type: "text", text: "[tools] please work" }], + }); + expect(result).toEqual({ stopReason: "end_turn" }); + + const text = client + .updatesOf("agent_message_chunk") + .map((u) => blockText(u.content)) + .join(""); + expect(text).toContain("Writing."); + expect(text).toContain("All done."); + + const calls = client.updatesOf("tool_call"); + expect(calls.map((c) => [c.name, c.kind, c.status])).toEqual([ + ["write", "edit", "in_progress"], + ["edit", "edit", "in_progress"], + ["bash", "execute", "in_progress"], + ]); + const notesPath = join(projectDir, "notes.txt"); + expect(calls[0].locations).toEqual([{ path: notesPath }]); + expect(calls[2].title).toBe("echo from-bash"); + + const done = client.updatesOf("tool_call_update").filter((u) => u.status !== "in_progress"); + expect(done.map((u) => u.status)).toEqual(["completed", "completed", "completed"]); + expect(done[0].content).toEqual([{ type: "diff", path: notesPath, oldText: null, newText: "alpha\nbeta\n" }]); + expect(done[1].content).toEqual([ + { type: "diff", path: notesPath, oldText: "alpha\nbeta\n", newText: "alpha\ngamma\n" }, + ]); + expect(toolText(done[2].content)).toContain("from-bash"); + expect(toolText(done[2].content).startsWith("```")).toBe(true); + expect(readFileSync(notesPath, "utf8")).toBe("alpha\ngamma\n"); + expect(client.updatesOf("usage_update").length).toBeGreaterThan(0); + + const listed = await client.request<{ sessions: SessionInfo[] }>("session/list", { cwd: projectDir }); + expect(listed.sessions.map((s) => s.sessionId)).toContain(sessionId); + }); + + it("shows a command's output while it runs", async () => { + const { client } = await startClient(); + const { sessionId } = await client.request("session/new", { cwd: projectDir, mcpServers: [] }); + await client.request("session/prompt", { sessionId, prompt: [{ type: "text", text: "[live] run it" }] }); + const updates = client.updatesOf("tool_call_update"); + const live = updates.filter( + (u) => toolText(u.content).includes("first") && !toolText(u.content).includes("second"), + ); + expect(live.length).toBeGreaterThan(0); + // A status on every update: some clients take an update without one for a finished call. + expect(live.every((u) => u.status === "in_progress")).toBe(true); + expect(updates.at(-1)?.status).toBe("completed"); + expect(toolText(updates.at(-1)?.content)).toContain("second"); + }); + + it("shows a command in a terminal for clients that take terminal output", async () => { + const runCommand = async (capabilities: object, script: string) => { + const { client } = await startClient([], capabilities); + const { sessionId } = await client.request("session/new", { + cwd: projectDir, + mcpServers: [], + }); + await client.request("session/prompt", { sessionId, prompt: [{ type: "text", text: `[${script}] run it` }] }); + const [call] = client.updatesOf("tool_call"); + return { call, updates: client.updatesOf("tool_call_update").filter((u) => u.toolCallId === call.toolCallId) }; + }; + const zed = { ...CLIENT_CAPABILITIES, terminal: true, _meta: { terminal_output: true } }; + + const { call, updates } = await runCommand(zed, "terminal"); + const id = call.toolCallId; + expect(call.content).toEqual([{ type: "terminal", terminalId: id }]); + expect(call._meta).toEqual({ terminal_info: { terminal_id: id, cwd: projectDir } }); + // Updates append only the new output, and carry no content (which would replace the terminal). + expect(updates.every((u) => u.content === undefined)).toBe(true); + const output = updates.slice(0, -1); + expect(output.every((u) => u.status === "in_progress")).toBe(true); + const data = output.map((u) => (u._meta?.terminal_output as { terminal_id: string; data: string }).data); + expect(output.every((u) => (u._meta?.terminal_output as { terminal_id: string }).terminal_id === id)).toBe(true); + expect(data[0]).toBe("first\n"); + expect(data.join("")).toBe("first\n\x1b[31msecond\x1b[0m\nno newline"); + expect(updates.at(-1)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: id, + status: "failed", + _meta: { terminal_exit: { terminal_id: id, exit_code: 3 } }, + }); + + // A command that does not finish has no exit code; the terminal says why it stopped. + const timedOut = await runCommand(zed, "timeout"); + const timedOutData = timedOut.updates + .slice(0, -1) + .map((u) => (u._meta?.terminal_output as { data: string }).data) + .join(""); + expect(timedOutData).toBe("started\nCommand timed out after 1 seconds\n"); + expect(timedOut.updates.at(-1)?._meta).toEqual({ + terminal_exit: { terminal_id: timedOut.call.toolCallId, exit_code: null }, + }); + + // Other clients get the output as text. + const plain = await runCommand(CLIENT_CAPABILITIES, "terminal"); + expect(plain.call.content).toBeUndefined(); + expect(plain.call._meta).toBeUndefined(); + expect(plain.updates.every((u) => u._meta === undefined)).toBe(true); + expect(plain.updates.at(-1)?.status).toBe("failed"); + expect(toolText(plain.updates.at(-1)?.content)).toContain("Command exited with code 3"); + }); + + it("cancels a running prompt", async () => { + const { client } = await startClient(); + const { sessionId } = await client.request("session/new", { cwd: projectDir, mcpServers: [] }); + const prompt = client.request("session/prompt", { + sessionId, + prompt: [{ type: "text", text: "[slow] wait" }], + }); + await new Promise((r) => setTimeout(r, 500)); + client.notify("session/cancel", { sessionId }); + expect(await prompt).toEqual({ stopReason: "cancelled" }); + }); + + it("loads a session from an earlier process and replays it", async () => { + const first = await startClient(); + const { sessionId } = await first.client.request("session/new", { + cwd: projectDir, + mcpServers: [], + }); + await first.client.request("session/prompt", { sessionId, prompt: [{ type: "text", text: "[hello] hi there" }] }); + expect(await first.client.stop()).toBe(0); + + const { client } = await startClient(); + const loaded = await client.request<{ configOptions: ConfigOption[] }>("session/load", { + sessionId, + cwd: projectDir, + mcpServers: [], + }); + expect(loaded.configOptions.length).toBeGreaterThan(0); + expect(client.updatesOf("user_message_chunk").map((u) => blockText(u.content))).toEqual(["[hello] hi there"]); + expect(client.updatesOf("agent_message_chunk").map((u) => blockText(u.content))).toEqual([ + "Hello from the fake model.", + ]); + + const resumed = await startClient(); + await resumed.client.request("session/resume", { sessionId, cwd: projectDir, mcpServers: [] }); + await new Promise((r) => setTimeout(r, 100)); + expect(resumed.client.updatesOf("user_message_chunk")).toEqual([]); + + const missing = await client.call("session/load", { sessionId: "nope", cwd: projectDir, mcpServers: [] }); + expect(missing.error?.code).toBe(-32002); + }); + + it("asks the editor when an extension asks the user", async () => { + const extensionPath = join(root, "ask-extension.ts"); + writeFileSync( + extensionPath, + `export default function (pi) { + pi.registerCommand("ask", { + description: "Ask a question", + handler: async (_args, ctx) => { + const ok = await ctx.ui.confirm("Proceed?", "Shall I go on?"); + const name = await ctx.ui.input("Your name"); + ctx.ui.notify("answer: " + ok + " " + name, "info"); + }, + }); +} +`, + ); + const { client } = await startClient(["-e", extensionPath]); + client.onRequest = (message) => + message.method === "session/request_permission" + ? { + outcome: { + outcome: "selected", + optionId: (message.params as RequestPermissionRequest).options[0].optionId, + }, + } + : { action: "accept", content: { value: "Ada" } }; + const { sessionId } = await client.request("session/new", { cwd: projectDir, mcpServers: [] }); + await client.waitFor(() => client.updatesOf("available_commands_update").length > 0); + expect(commandNames(client)).toContain("ask"); + expect( + await client.request("session/prompt", { + sessionId, + prompt: [{ type: "text", text: "/ask" }], + }), + ).toEqual({ + stopReason: "end_turn", + }); + expect(client.clientRequests.map((m) => m.method)).toEqual(["session/request_permission", "elicitation/create"]); + const dialog = (client.clientRequests[0].params as RequestPermissionRequest).toolCall; + expect(dialog.title).toBe("Proceed?"); + // The dialog's own entry ends once answered. + expect(client.updatesOf("tool_call_update").filter((u) => u.toolCallId === dialog.toolCallId)).toEqual([ + { sessionUpdate: "tool_call_update", toolCallId: dialog.toolCallId, status: "completed" }, + ]); + expect(client.updatesOf("notice").map((u) => u.title)).toContain("answer: true Ada"); + }); + + it("asks about a running tool on its own entry and gives the entry back", async () => { + const extensionPath = join(root, "gate-extension.ts"); + writeFileSync( + extensionPath, + `export default function (pi) { + pi.on("tool_call", async (event, ctx) => { + if (!(await ctx.ui.confirm("Allow bash?", "It runs a command"))) return { block: true, reason: "denied" }; + }); +} +`, + ); + const { client } = await startClient(["-e", extensionPath]); + client.onRequest = (message) => ({ + outcome: { outcome: "selected", optionId: (message.params as RequestPermissionRequest).options[0].optionId }, + }); + const { sessionId } = await client.request("session/new", { cwd: projectDir, mcpServers: [] }); + await client.request("session/prompt", { sessionId, prompt: [{ type: "text", text: "[live] run it" }] }); + const [call] = client.updatesOf("tool_call"); + const asked = (client.clientRequests[0].params as RequestPermissionRequest).toolCall; + expect(asked).toMatchObject({ toolCallId: call.toolCallId, title: "Allow bash?" }); + const updates = client.updatesOf("tool_call_update").filter((u) => u.toolCallId === call.toolCallId); + expect(updates[0]).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: call.toolCallId, + title: call.title, + content: [], + }); + expect(updates.at(-1)?.status).toBe("completed"); + }); + + it("connects the MCP servers the editor passes, with their environment as given", async () => { + const serverPath = join(root, "mcp-server.mjs"); + const marker = join(root, "mcp-marker.json"); + writeFileSync( + serverPath, + `import { writeFileSync } from "node:fs"; +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf("\\n")) !== -1) { + const message = JSON.parse(buffer.slice(0, index)); + buffer = buffer.slice(index + 1); + if (message.id === undefined) continue; + let result = {}; + if (message.method === "initialize") { + writeFileSync(process.env.MARKER, JSON.stringify({ token: process.env.TOKEN })); + result = { protocolVersion: message.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "ide", version: "1" } }; + } else if (message.method === "tools/list") { + result = { tools: [] }; + } + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }) + "\\n"); + } +}); +`, + ); + const { client } = await startClient(); + await client.request("session/new", { + cwd: projectDir, + mcpServers: [ + { + name: "IDE tools", + command: process.execPath, + args: [serverPath], + env: [ + { name: "MARKER", value: marker }, + { name: "TOKEN", value: EDITOR_TOKEN }, + ], + }, + ], + }); + await client.waitFor(() => { + try { + return JSON.parse(readFileSync(marker, "utf8")).token === EDITOR_TOKEN; + } catch { + return false; + } + }); + }); + + it("changes the thinking level and model through config options", async () => { + const { client } = await startClient(); + const { sessionId } = await client.request("session/new", { cwd: projectDir, mcpServers: [] }); + const result = await client.request<{ configOptions: ConfigOption[] }>("session/set_config_option", { + sessionId, + configId: "model", + value: "fake/fake-model", + }); + expect(result.configOptions.find((option) => option.id === "model")?.currentValue).toBe("fake/fake-model"); + const bad = await client.call("session/set_config_option", { sessionId, configId: "model", value: "x/y" }); + expect(bad.error?.code).toBe(-32602); + }); + + it("offers only the models of --models", async () => { + const { client } = await startClient(["--models", "fake/fake-model"]); + const { sessionId, configOptions } = await client.request("session/new", { + cwd: projectDir, + mcpServers: [], + }); + const model = configOptions.find((option) => option.id === "model"); + expect(model?.options.map((choice) => choice.value)).toEqual(["fake/fake-model"]); + const outside = await client.call("session/set_config_option", { + sessionId, + configId: "model", + value: "fake/other-model", + }); + expect(outside.error?.code).toBe(-32602); + }); + + it("fails a session whose runtime reports an error", async () => { + const { client } = await startClient(["--provider", "no-such-provider", "--model", "fake-model"]); + const response = await client.call("session/new", { cwd: projectDir, mcpServers: [] }); + expect(response.error?.code).toBe(-32603); + expect(response.error?.message).toContain("no-such-provider"); + }); + + it("lists the sessions of --session-dir without a directory filter", async () => { + const sessionDir = join(root, "custom-sessions"); + const { client } = await startClient(["--session-dir", sessionDir]); + const { sessionId } = await client.request("session/new", { cwd: projectDir, mcpServers: [] }); + await client.request("session/prompt", { + sessionId, + prompt: [{ type: "text", text: "[hello] stored elsewhere" }], + }); + const listed = await client.request<{ sessions: SessionInfo[] }>("session/list", {}); + expect(listed.sessions.map((session) => session.sessionId)).toContain(sessionId); + }); +}); diff --git a/packages/coding-agent/test/acp-translate.test.ts b/packages/coding-agent/test/acp-translate.test.ts new file mode 100644 index 000000000..d04d4c76b --- /dev/null +++ b/packages/coding-agent/test/acp-translate.test.ts @@ -0,0 +1,270 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { describe, expect, it } from "vitest"; +import { OutputAccumulator } from "../src/core/tools/output-accumulator.ts"; +import { truncationDetails } from "../src/core/tools/truncate.ts"; +import { AcpConnection } from "../src/extensions/acp/connection.ts"; +import { literalConfigValue, mcpServerName, toMcpServerConfig } from "../src/extensions/acp/extension.ts"; +import { + bashExitCode, + bashOutputTail, + fenced, + promptToPi, + replayMessages, + stopReasonOf, + TERMINAL_START, + type TerminalSent, + terminalOutputDelta, + toolCallInfo, + toolResultContent, +} from "../src/extensions/acp/translate.ts"; + +const cwd = join("/", "work", "project"); +const file = join(cwd, "src", "main.ts"); + +describe("promptToPi", () => { + it("keeps text and links in place and puts embedded files first", () => { + const prompt = promptToPi([ + { type: "text", text: "Explain" }, + { type: "resource_link", uri: pathToFileURL(file).href, name: "main.ts" }, + { type: "text", text: " please" }, + { type: "resource", resource: { uri: pathToFileURL(file).href, text: "export {}" } }, + { type: "image", data: "aGk=", mimeType: "image/png" }, + ]); + expect(prompt.text).toBe(`\nexport {}\n\nExplain ${file} please`); + expect(prompt.images).toEqual([{ type: "image", data: "aGk=", mimeType: "image/png" }]); + }); + + it("passes slash commands through unchanged", () => { + expect(promptToPi([{ type: "text", text: "/skill:review now" }]).text).toBe("/skill:review now"); + }); +}); + +describe("toolCallInfo", () => { + it("describes Pi's tools", () => { + expect(toolCallInfo("read", { path: "src/main.ts", offset: 10 }, cwd)).toEqual({ + title: "Read src/main.ts", + kind: "read", + locations: [{ path: file, line: 9 }], + }); + expect(toolCallInfo("edit", { path: file, edits: [] }, cwd).kind).toBe("edit"); + expect(toolCallInfo("bash", { command: "npm test\n--watch" }, cwd)).toEqual({ + title: "npm test --watch", + kind: "execute", + locations: [], + }); + expect(toolCallInfo("grep", { pattern: "TODO" }, cwd).kind).toBe("search"); + expect(toolCallInfo("my_tool", {}, cwd)).toEqual({ title: "my_tool", kind: "other", locations: [] }); + }); +}); + +describe("tool results", () => { + it("keeps text and images", () => { + expect( + toolResultContent({ + content: [ + { type: "text", text: "ok" }, + { type: "image", data: "aGk=", mimeType: "image/png" }, + { type: "text", text: "" }, + ], + }), + ).toEqual([ + { type: "content", content: { type: "text", text: "ok" } }, + { type: "content", content: { type: "image", data: "aGk=", mimeType: "image/png" } }, + ]); + }); + + it("fences output with a fence longer than any it contains", () => { + expect(fenced("a ``` b")).toBe("````\na ``` b\n````"); + }); +}); + +describe("history", () => { + const messages = [ + { role: "user", content: "hi", timestamp: 0 }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "hmm" }, + { type: "text", text: "Reading." }, + { type: "toolCall", id: "t1", name: "read", arguments: { path: "src/main.ts" } }, + ], + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "t1", + toolName: "read", + content: [{ type: "text", text: "code" }], + isError: false, + }, + { role: "assistant", content: [{ type: "text", text: "Done." }], stopReason: "length" }, + ] as unknown as AgentMessage[]; + + it("replays messages and tool calls with their results", () => { + const updates = replayMessages(messages, cwd).map((update) => update.sessionUpdate); + expect(updates).toEqual([ + "user_message_chunk", + "agent_thought_chunk", + "agent_message_chunk", + "tool_call", + "agent_message_chunk", + ]); + const call = replayMessages(messages, cwd)[3]; + expect(call).toMatchObject({ status: "completed", locations: [{ path: file }] }); + }); + + it("maps the last assistant stop reason", () => { + expect(stopReasonOf(messages, false)).toBe("max_tokens"); + expect(stopReasonOf(messages, true)).toBe("cancelled"); + expect(stopReasonOf([], false)).toBe("end_turn"); + }); +}); + +describe("editor MCP servers", () => { + it("keeps values literal and names valid", () => { + // biome-ignore lint/suspicious/noTemplateCurlyInString: literal config value reference + expect(literalConfigValue("!rm -rf $HOME ${X}")).toBe("$!rm -rf $$HOME $${X}"); + expect(mcpServerName("IDE tools (JetBrains)")).toBe("IDE-tools-JetBrains"); + expect(mcpServerName("***")).toBe("server"); + expect(toMcpServerConfig({ type: "sse", name: "x", url: "http://localhost", headers: [] })).toBeUndefined(); + expect( + toMcpServerConfig({ type: "http", name: "x", url: "http://localhost", headers: [{ name: "A", value: "$b" }] }), + ).toEqual({ type: "http", url: "http://localhost", headers: { A: "$$b" } }); + }); +}); + +describe("AcpConnection", () => { + const connect = () => { + const sent: Array<{ id: number | null }> = []; + const connection = new AcpConnection({ + write: (line) => sent.push(JSON.parse(line)), + onRequest: async (method) => { + if (method === "boom") throw new Error("broken"); + return { ok: method }; + }, + onNotification: () => {}, + }); + return { connection, sent }; + }; + + it("answers requests and errors", async () => { + const { connection, sent } = connect(); + connection.receive('{"jsonrpc":"2.0","id":1,"method":"ping"}'); + connection.receive('{"jsonrpc":"2.0","id":2,"method":"boom"}'); + connection.receive("not json"); + await new Promise((r) => setTimeout(r, 0)); + expect(sent).toEqual([ + { jsonrpc: "2.0", id: null, error: { code: -32700, message: expect.any(String) } }, + { jsonrpc: "2.0", id: 1, result: { ok: "ping" } }, + { jsonrpc: "2.0", id: 2, error: { code: -32603, message: "broken" } }, + ]); + }); + + it("matches the client's responses to the agent's requests", async () => { + const { connection, sent } = connect(); + const answer = connection.request("session/request_permission", {}); + connection.receive( + JSON.stringify({ jsonrpc: "2.0", id: sent[0].id, result: { outcome: { outcome: "cancelled" } } }), + ); + expect(await answer).toEqual({ outcome: { outcome: "cancelled" } }); + const failed = connection.request("x", {}); + connection.close(); + await expect(failed).rejects.toThrow("closed"); + }); +}); + +describe("terminalOutputDelta", () => { + /** Command output as the bash tool reports it while the command runs: the end of it, with small limits. */ + const command = () => { + const output = new OutputAccumulator({ maxLines: 4, maxBytes: 40, tempFilePrefix: "pi-acp-test" }); + let full = ""; + let sent: TerminalSent = TERMINAL_START; + let shown = ""; + return { + write(text: string) { + output.append(Buffer.from(text, "utf8")); + full += text; + }, + /** What the terminal is sent for the bash tool's next report. */ + report(): string { + const snapshot = output.snapshot(); + const tail = bashOutputTail({ + content: [{ type: "text", text: snapshot.content }], + details: { + truncation: snapshot.truncation.truncated ? truncationDetails(snapshot.truncation) : undefined, + }, + }); + const delta = terminalOutputDelta(sent, tail); + sent = delta.sent; + shown += delta.data; + return delta.data; + }, + get full() { + return full; + }, + get shown() { + return shown; + }, + close() { + output.closeTempFile(); + const path = output.snapshot().fullOutputPath; + if (path) rmSync(path, { force: true }); + }, + }; + }; + + it("sends only new output, through the bash tool's truncated tail", () => { + const run = command(); + const chunks = [ + "one\n", + "two\nthr", + "ee\n", + "y\ny\n", + "y\ny\n", + "y\ny\n", + "été\n", + "\x1b[32mok\x1b[0m\n", + "x".repeat(30), + "x".repeat(30), + "\nlast", + " line", + ]; + for (const chunk of chunks) { + run.write(chunk); + run.report(); + } + expect(run.report()).toBe(""); + expect(run.shown).toBe(run.full); + run.close(); + }); + + it("marks output that went by between two reports, and goes on from there", () => { + const run = command(); + run.write("start\n"); + expect(run.report()).toBe("start\n"); + run.write(Array.from({ length: 20 }, (_, i) => `line ${i}\n`).join("")); + // The tail leaves out the trailing newline, so where the terminal stands is known only to a byte. + expect(run.report()).toBe("[… output skipped …]\nline 16\nline 17\nline 18\nline 19"); + run.write("more"); + expect(run.report()).toBe("\nmore"); + run.write(" and more\n"); + expect(run.report()).toBe(" and more"); + run.close(); + }); + + it("starts over when the output changes instead of growing", () => { + const sent = { text: "progress 50%", end: 12, exact: true }; + const delta = terminalOutputDelta(sent, { text: "done", totalBytes: 4, truncated: false }); + expect(delta.data).toBe("\x1b[H\x1b[2J\x1b[3Jdone"); + expect(delta.sent).toEqual({ text: "done", end: 4, exact: true }); + }); + + it("takes the exit code of a command that finished", () => { + expect(bashExitCode({ content: [], structuredContent: { exit_code: 3 } }, true)).toBe(3); + expect(bashExitCode({ content: [{ type: "text", text: "Command aborted" }] }, true)).toBeNull(); + expect(bashExitCode({ content: [] }, false)).toBe(0); + }); +}); diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 2874bafd4..cee4f5385 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -155,12 +155,18 @@ describe("parseArgs", () => { // Issue #9045 describe("--mode flag", () => { - test.each(["text", "json", "rpc"] as const)("parses --mode %s", (mode) => { + test.each(["text", "json", "rpc", "acp"] as const)("parses --mode %s", (mode) => { const result = parseArgs(["--mode", mode]); expect(result.mode).toBe(mode); expect(result.diagnostics).toEqual([]); }); + test("parses --acp as --mode acp", () => { + const result = parseArgs(["--acp"]); + expect(result.mode).toBe("acp"); + expect(result.diagnostics).toEqual([]); + }); + test.each(["yaml", ""])("rejects invalid --mode value %j", (mode) => { const result = parseArgs(["--mode", mode, "--version"]); expect(result.mode).toBeUndefined(); @@ -168,7 +174,7 @@ describe("parseArgs", () => { expect(result.messages).toEqual([]); expect(result.unknownFlags.size).toBe(0); expect(result.diagnostics).toEqual([ - { type: "error", message: `Invalid mode "${mode}". Valid values: text, json, rpc` }, + { type: "error", message: `Invalid mode "${mode}". Valid values: text, json, rpc, acp` }, ]); }); @@ -176,7 +182,7 @@ describe("parseArgs", () => { const result = parseArgs(["--mode"]); expect(result.mode).toBeUndefined(); expect(result.unknownFlags.size).toBe(0); - expect(result.diagnostics).toEqual([{ type: "error", message: "--mode requires text, json, or rpc" }]); + expect(result.diagnostics).toEqual([{ type: "error", message: "--mode requires text, json, rpc, or acp" }]); }); test("does not consume another option as a --mode value", () => { @@ -184,13 +190,13 @@ describe("parseArgs", () => { expect(result.mode).toBeUndefined(); expect(result.version).toBe(true); expect(result.unknownFlags.size).toBe(0); - expect(result.diagnostics).toEqual([{ type: "error", message: "--mode requires text, json, or rpc" }]); + expect(result.diagnostics).toEqual([{ type: "error", message: "--mode requires text, json, rpc, or acp" }]); }); test("reports an invalid --mode value after a valid one", () => { const result = parseArgs(["--mode", "json", "--mode", "yaml"]); expect(result.diagnostics).toEqual([ - { type: "error", message: 'Invalid mode "yaml". Valid values: text, json, rpc' }, + { type: "error", message: 'Invalid mode "yaml". Valid values: text, json, rpc, acp' }, ]); }); });