diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 566ba909..c6a246c0 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -85,7 +85,7 @@ import { parseArgs, resolveAppMode, toPrintOutputMode } from "#args/index"; import { loadStepStartupConfig } from "#bootstrap/config"; import { createStepExtensionFactories } from "#bootstrap/extensions"; import { captureRawStdout, sdkStdioRequested } from "#bootstrap/stdout-capture"; -import { InteractiveMode, runPrintMode, runRpcMode } from "#modes/index"; +import { InteractiveMode, runAcpMode, runPrintMode, runRpcMode } from "#modes/index"; import { createSdkStdioMode } from "#modes/sdk-stdio"; import { selectConfig, @@ -237,7 +237,7 @@ const stepMainOptions: MainOptions = { present: showUsagePanel, output: (ctx, model) => { const text = usagePresentationRows(model).join("\n"); - if (ctx.mode === "rpc") ctx.ui.notify(text, "info"); + if (ctx.mode === "rpc" || ctx.mode === "acp") ctx.ui.notify(text, "info"); else process.stderr.write(`${text}\n`); }, }, @@ -888,6 +888,10 @@ async function dispatchStepAppMode(prep: Extract [out]` | Export session to HTML | In print mode, step also reads piped stdin and merges it into the initial prompt: diff --git a/packages/coding-agent/docs/acp.md b/packages/coding-agent/docs/acp.md new file mode 100644 index 00000000..a59ffa56 --- /dev/null +++ b/packages/coding-agent/docs/acp.md @@ -0,0 +1,170 @@ +# ACP mode + +`step --mode acp` runs StepCode as an [Agent Client Protocol](https://agentclientprotocol.com/) v1 agent over stdio. The wire format is newline-delimited JSON-RPC 2.0, the same framing family as RPC mode, but the method names and payloads follow ACP. + +Use this mode when an ACP client (an editor, desktop shell, or another agent host) needs to drive StepCode directly. + +## Starting + +```bash +step --mode acp +``` + +The process speaks ACP on stdin/stdout. Diagnostics and extension notices go to stderr, so stdout stays a clean JSON-RPC channel. `@file` arguments are rejected in ACP mode, just like RPC mode. + +stdin is never read as a piped prompt in this mode, for the same reason as RPC mode: it is the JSON-RPC transport. Draining it would swallow the protocol stream, and a client that keeps the pipe open for the whole session would hang startup on a read that waits for `end`. + +## Methods + +| Method | Direction | Notes | +|--------|-----------|-------| +| `initialize` | client → agent | Negotiates protocol version, capabilities, and auth methods. | +| `authenticate` | client → agent | Completes the API-key method. The terminal method is run by the client. | +| `logout` | client → agent | Removes the stored Step credential and account session. | +| `session/new` | client → agent | Creates a session. The `cwd` must match the agent working directory. | +| `session/load` | client → agent | Loads an existing StepCode session by ACP `sessionId`, which is the StepCode session id, and replays the stored conversation as `session/update` before answering. | +| `session/resume` | client → agent | Reattaches to a session without replaying history. There is no `replayFrom` switch: the protocol makes replay `load`'s job and forbids it here. | +| `session/list` | client → agent | Lists sessions, optionally filtered by `cwd`. | +| `session/delete` | client → agent | Deletes a session file. The active session cannot be deleted. | +| `session/close` | client → agent | Cancels the active turn and frees the session, keeping it on disk. | +| `session/prompt` | client → agent | Sends one user turn. Supports text, images, resource links, and embedded resources. | +| `session/cancel` | client → agent | Notification that cancels the active turn. | +| `session/set_mode` | client → agent | Switches the permission tier. | +| `session/set_config_option` | client → agent | Changes the model, or the reasoning level when the model has one. | +| `session/set_model` | client → agent | Alias for the model half of `session/set_config_option`. Outside the v1 schema. | +| `session/fork` | client → agent | Copies a session's history into a new session and makes it active. Outside the v1 schema. | +| `session/update` | agent → client | Streaming notification for message chunks, thought chunks, tool calls, plans, mode and config changes. | +| `session/request_permission` | agent → client | Tool approval request. Offers allow-once, allow-always, reject-once, and reject-always. | +| `elicitation/create` | agent → client | Asks the user a structured question when an extension needs input. | + +`session/prompt` responds only after the turn finishes, with `stopReason` set to `end_turn` or `cancelled`. + +Every optional session method is advertised in `initialize` through `sessionCapabilities`. ACP gates those methods behind their capability, so a client that follows the spec will not call one that is not advertised. + +## Session identity + +`session/new` and `session/load` return the StepCode session id as the ACP `sessionId`. A later `session/load` can restore that id after a client restart. Loading a session whose recorded `cwd` does not match the request `cwd` is rejected. + +## Prompt content + +The baseline accepts: + +- `text` +- `image` with base64 `data` and `mimeType` +- `resource_link`, rendered into the prompt as `[[name]](uri)` +- `resource` with inline `text` or a base64 `blob`, rendered with its contents so the model does not have to fetch it + +`promptCapabilities.embeddedContext` is `true` because `resource` blocks are accepted. + +A turn that fails is reported as a JSON-RPC error, not as `stopReason: "end_turn"`. A failed model call does not reject the engine's `prompt()`; it is recorded on the assistant message, and ACP v1 has no error stop reason (its `StopReason` covers `end_turn`, `max_tokens`, `max_turn_requests`, `refusal` and `cancelled`). Answering `end_turn` would tell the client the agent replied when it did not, so the error channel carries the engine's message instead. The failed assistant message stays in the session, so a client can still resume it. + +## Permissions + +Tool approval uses ACP `session/request_permission`: + +- `allow_once` approves the pending tool call. +- `reject_once` rejects it. +- `allow_always` approves it and stops asking for the rest of the session. +- `reject_always` rejects it and stops asking for the rest of the session. +- A cancelled request rejects it. + +The two `always` answers are the scope the protocol offers, so they are honoured as one flag per session rather than as a per-tool rule the protocol has no way to express. They are cleared when the active session changes, because carrying an allow across a switch would approve tool calls in a session whose user never consented to them. When both are somehow set, the rejection wins: a contradictory pair must not read as consent. + +## Elicitation + +`elicitation/create` is used when an extension asks the user something that a yes/no approval cannot express. Only the `form` mode is implemented; URL-based elicitation needs a client that can open a browser and report completion, which the baseline does not assume. + +The client decides how much of this surface exists. A client that advertises `clientCapabilities.elicitation.form` gets real dialogs: + +- extension `select()` offers the actual options, and returns the one the user picked. +- extension `input()` asks for free-form text. + +A client that does not advertise form elicitation keeps the previous behavior: `select()` falls back to an allow/reject approval and returns the first option when approved, and `input()` resolves as dismissed. Both are honest about what the protocol can carry rather than guessing an answer. + +## Authentication + +`initialize` advertises the methods this agent can offer: + +- `step_login`, type `terminal`, only when the client advertises `clientCapabilities.auth.terminal`. The client runs `step login` itself as an interactive process, which is the only way the browser flow can happen. The protocol forbids passing this method to `authenticate`. +- `step_api_key`, which the agent completes through elicitation. It asks for the account profile and the key, then writes the credential to the same store `step login` uses, so a later process finds it without asking again. + +`logout` removes the stored credential and the account session. Sessions already running keep their in-memory credential until they end, because the protocol makes no promise about them and pulling it mid-turn would fail a turn that is already streaming. + +When the active model is a Step model and no credential exists, `session/new`, `session/load`, and `session/resume` fail with the ACP `auth_required` error instead of letting the turn fail on the first request. Only the Step provider is gated, and only on the credential sources this product owns: `STEP_API_KEY` and the stored credential. Gating on the runtime provider snapshot would reject sessions before the first availability refresh has run, which is a startup race rather than a missing credential. + +## Session modes + +Modes are the Step permission tiers, which are what ACP modes describe: a named policy that changes tool availability and how much the agent may do without asking. + +| Mode | Meaning | +|------|---------| +| `ask` | Ask before modifying files or running commands. | +| `approve-for-me` | Routine actions run automatically; only potentially unsafe actions and key decisions ask. | +| `full-access` | Also runs detected dangerous commands without confirmation. | + +`session/set_mode` runs the same extension command the TUI runs, so the policy stays in one place. That also keeps the full-access risk confirmation, which the extension routes back through `session/request_permission` like any other approval. The current mode is reported in `session/new`, `session/load`, and `session/resume`, and a change emits a `current_mode_update` notification. + +## Session config options + +`session/set_config_option` exposes the selectors the session can actually change: + +- `model`, category `model`. Values are `/`. The list comes from the models the runtime can reach, so a client never offers a model that cannot be selected. +- `thinking`, category `thought_level`, only when the active model supports reasoning. + +An option this session cannot change is rejected rather than accepted and ignored. The current values are reported in the session lifecycle responses, and a change emits a `config_option_update` notification. + +## MCP servers + +**Scope: local debugging.** `session/new` and `session/load` accept the client's `mcpServers` list. The servers are connected for that session and their tools are exposed to the model like any other MCP server, on top of the ones already in `config.toml` and plugin manifests. + +This is a local debugging surface, not a second configuration path. A stdio ACP session shares one machine and one filesystem with its client, so what a client hands over is a server the user is trying out on this machine. Durable configuration stays in `config.toml` and plugin manifests, and `/mcp` plus the session status view remain the way to inspect what is actually connected. + +- stdio servers (`command`, `args`, `env`) are the protocol's mandatory transport and are always accepted. In ACP v1 a stdio config carries no `type` field, so an absent `type` is read as stdio rather than rejected. +- `type: "http"` servers (`url`, `headers`) are accepted; `initialize` advertises `mcpCapabilities.http: true`. +- `type: "sse"` servers (`url`, `headers`) are accepted; `initialize` advertises `mcpCapabilities.sse: true`. The protocol marks the transport deprecated and ACP v2 removes it, but it still defines it and clients still send it, so it is connected through its own transport rather than reported and dropped. Which transport a `url` uses is read from the `type` the client named, never guessed from the URL, which cannot be told apart from a streamable-HTTP one. Headers are applied to both legs, because the stream request and the POSTs are separate. OAuth is not wired for SSE: the streamable-HTTP path owns that, so an SSE server that needs it fails at startup instead of half-working. +- A client server replaces a `config.toml` entry of the same name, so a stale global entry can be overridden per session. Plugin servers are namespaced `__`, so they never collide with a client's names. +- A session that sends no servers starts with none of the previous client's servers. +- The unstable `acp` transport type and entries that cannot start are reported on stderr rather than dropped silently, so a missing server is visible. + +## Client filesystem + +`fs/read_text_file` and `fs/write_text_file` are client-side methods: the agent asks, the client answers. They are bridged into this mode's `read`, `write`, and `edit` tools, gated on what the client advertised in `clientCapabilities.fs`. + +The two directions are gated independently, because ACP gates them independently. A client that offers only reads gets a bridge with only reads; writes stay local rather than failing. + +Why a round-trip through the client is worth it, when the client and the agent share one filesystem: + +- A client read returns the editor's buffer, including changes the user has not saved. A local read returns whatever is on disk, which for an unsaved file is the older content. +- A client write lets the client track what the agent changed, so it can show a diff or mark the file touched. + +Images are the exception and stay local. The method is named `read_text_file`, so an image routed through it would come back as text and the tool would lose the bytes it needs to attach. Image paths are detected and read from disk. + +A failed read falls back to the local file and says so on stderr. A client that advertises the method and then answers `methodNotFound` should not turn every read into a hard failure for a file sitting right there. A failed write has no such fallback and propagates: writing the same bytes once where the client can see it and once where it cannot is worse than one visible failure. + +## Client terminals + +`terminal/create`, `terminal/output`, `terminal/wait_for_exit`, `terminal/kill`, and `terminal/release` are bridged into this mode's `bash` tool, gated on `clientCapabilities.terminal`. Commands then run in the client's terminal, so the user sees them where they already are. + +Two details are worth knowing: + +- ACP takes a program plus arguments, while the agent's command pipeline is a shell string. The resolved shell config supplies the program and its flags and the string rides as the final argument, so the client runs the same command line the local path would have. +- ACP has no push channel for terminal output, so the agent polls `terminal/output` while a command runs. Output is emitted as it appears; a buffer the client truncated from the front is not re-emitted, because that would duplicate output the accumulator already holds. + +Execution falls back to the local shell in exactly two cases, both decided before the command starts: + +- No client terminal is installed, which is every non-ACP run and every ACP client that did not advertise `terminal`. +- The resolved shell feeds the command over stdin. ACP has no channel for that, so it cannot be expressed. + +Once `terminal/create` has been answered the command is running, and no later failure is retried locally. Re-running a command that already deleted a file or pushed a commit is not a fallback, it is a second side effect. + +## Still not in this mode + +`elicitation/complete` is not implemented: it reports which option a form field resolved to, and only a client that renders forms can answer it. + +`additionalDirectories` is validated on `session/new`, `session/load`, and `session/resume` and reported on stderr when it cannot be honoured, but it does not expand the session's filesystem scope yet. A stdio session's tools resolve every path against one working directory, so extra roots would need path resolution re-plumbed through every tool. Reporting it beats staying silent, which would leave the client believing it had granted workspace roots the agent cannot reach. + +`session/fork` and `session/set_model` are outside the ACP v1 schema. They are implemented because clients that talk to agents which carry them will send them. Their responses mirror `session/new` and `session/set_config_option` respectively, because there is no spec shape to follow. + +## Relationship to RPC mode + +ACP mode reuses the same `AgentSessionRuntimeHost` as RPC mode. Session persistence, extensions, compaction, tool execution, and provider behavior are shared. Only the protocol surface is different. RPC mode remains the lower-level JSONL channel for process integration. diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index fdfe9f67..32bf15d4 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2952,6 +2952,7 @@ const highlighted = highlightCode(code, lang, theme); |------|------------|-------------|-------| | Interactive | `"tui"` | `true` | Full TUI with terminal rendering | | RPC (`--mode rpc`) | `"rpc"` | `true` | Dialogs and notifications via JSON protocol; `custom()` returns `undefined`. See [rpc.md](rpc.md) | +| ACP (`--mode acp`) | `"acp"` | `true` | ACP permission requests and stderr notices; `input()`, `editor()`, and `custom()` are unsupported. See [acp.md](acp.md) | | JSON (`--mode json`) | `"json"` | `false` | Event stream to stdout; UI methods are no-ops | | Print (`-p`) | `"print"` | `false` | Extensions run but can't prompt | diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 1bf9eed0..ed1b4216 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -154,7 +154,7 @@ cat README.md | step -p "Summarize this text" step -p @screenshot.png "What's in this image?" ``` -Use `--mode json` for JSON event output or `--mode rpc` for process integration. +Use `--mode json` for JSON event output, `--mode rpc` for process integration, or `--mode acp` for Agent Client Protocol clients. ## Next steps diff --git a/packages/coding-agent/docs/security.md b/packages/coding-agent/docs/security.md index 687da984..7e452b74 100644 --- a/packages/coding-agent/docs/security.md +++ b/packages/coding-agent/docs/security.md @@ -26,7 +26,7 @@ Trusting a project allows step to load project resources that require trust, inc Declining trust skips protected resources. Context files such as `AGENTS.override.md`, `AGENTS.md`, and `CLAUDE.md` are loaded regardless of project trust unless context loading is disabled. Before trust is resolved, step only loads context files, user/global extensions, and CLI `-e` extensions. User/global and CLI extensions can handle the `project_trust` event; the first extension that returns a yes/no decision owns the decision. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, `defaultProjectTrust: "ask"` and `"never"` ignore such resources, while `"always"` trusts them. Use `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. +Non-interactive modes (`-p`, `--mode json`, `--mode rpc`, and `--mode acp`) do not show a trust prompt. Without an applicable saved trust decision, `defaultProjectTrust: "ask"` and `"never"` ignore such resources, while `"always"` trusts them. Use `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. ## No Built-in Sandbox diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 4b3a12bc..46819d79 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -13,7 +13,7 @@ Edit directly or use `/settings` for common options. To save startup model defau On interactive startup, step asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` or `.claude/skills` and has no saved decision for the folder or a parent folder in `~/.stepcode/agent/trust.json`. Trusting a project allows step to load `.stepcode/settings.json` and `.stepcode` resources, install missing project packages, and execute project extensions. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. +Non-interactive modes (`-p`, `--mode json`, `--mode rpc`, and `--mode acp`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.stepcode/agent/settings.json`, or change it with `/settings`. diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index f0a081d5..ae1d122c 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -123,7 +123,7 @@ On interactive startup, step asks before trusting a project folder that contains Before the trust decision, step loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. +Non-interactive modes (`-p`, `--mode json`, `--mode rpc`, and `--mode acp`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.stepcode/agent/settings.json`, or change it with `/settings`. @@ -172,6 +172,7 @@ See [Step Packages](packages.md) for package sources and security notes. | `-p`, `--print` | Print response and exit | | `--mode json` | Output all events as JSON lines; see [JSON mode](json.md) | | `--mode rpc` | RPC mode over stdin/stdout; see [RPC mode](rpc.md) | +| `--mode acp` | Agent Client Protocol v1 over stdio; see [ACP mode](acp.md) | | `--export [out]` | Export a session to HTML | In print mode, step also reads piped stdin and merges it into the initial prompt: diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index d9d7431d..eec4f7f6 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -15,7 +15,7 @@ import type { StepToolPermissionMode, } from "../step/permissions.ts"; -export type Mode = "text" | "json" | "rpc"; +export type Mode = "text" | "json" | "rpc" | "acp"; export interface Args { provider?: string; @@ -213,7 +213,7 @@ export function parseArgs(args: string[]): Args { const taken = takeOptionValue(args, i, "--mode", result); if (taken) { i = taken.nextIndex; - if (taken.value === "text" || taken.value === "json" || taken.value === "rpc") { + if (taken.value === "text" || taken.value === "json" || taken.value === "rpc" || taken.value === "acp") { result.mode = taken.value; } } @@ -574,7 +574,7 @@ export function parseArgs(args: string[]): Args { if (result.completionCheck) { result.completionCheckAttempts ??= 2; - if (result.mode === "rpc" || result.sdkStdio) { + if (result.mode === "rpc" || result.mode === "acp" || result.sdkStdio) { result.diagnostics.push({ type: "error", message: "--completion-check is only supported in print or JSON mode", @@ -586,7 +586,11 @@ export function parseArgs(args: string[]): Args { message: "--completion-check-attempts requires --completion-check git-committed", }); } - if (result.completionReview && !result.completionCheck && (result.mode === "rpc" || result.sdkStdio)) { + if ( + result.completionReview && + !result.completionCheck && + (result.mode === "rpc" || result.mode === "acp" || result.sdkStdio) + ) { result.diagnostics.push({ type: "error", message: "--completion-review is only supported in print or JSON mode", @@ -667,7 +671,7 @@ ${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 ${stepPermissionOptionsText} --sdk-stdio Run the Step Agent SDK length-prefixed stdio host --print, -p Non-interactive mode: process prompt and exit diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 7fc04617..90f2abcb 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -367,7 +367,7 @@ export interface CompactOptions { /** * Context passed to extension event handlers. */ -export type ExtensionMode = "tui" | "rpc" | "json" | "print"; +export type ExtensionMode = "tui" | "rpc" | "acp" | "json" | "print"; export interface ExtensionContext { /** UI methods for user interaction */ @@ -937,7 +937,7 @@ export interface UserBashEvent { // ============================================================================ /** Source of user input */ -export type InputSource = "interactive" | "rpc" | "extension"; +export type InputSource = "interactive" | "rpc" | "acp" | "extension"; /** Fired when user input is received, before agent processing */ export interface InputEvent { diff --git a/packages/coding-agent/src/core/project-trust.ts b/packages/coding-agent/src/core/project-trust.ts index 35f5ce90..109761fd 100644 --- a/packages/coding-agent/src/core/project-trust.ts +++ b/packages/coding-agent/src/core/project-trust.ts @@ -9,7 +9,7 @@ import { type ProjectTrustStore, } from "./trust-manager.ts"; -export type AppMode = "interactive" | "print" | "json" | "rpc"; +export type AppMode = "interactive" | "print" | "json" | "rpc" | "acp"; export interface ResolveProjectTrustedOptions { cwd: string; diff --git a/packages/coding-agent/src/core/tools/client-fs.ts b/packages/coding-agent/src/core/tools/client-fs.ts new file mode 100644 index 00000000..832bc74f --- /dev/null +++ b/packages/coding-agent/src/core/tools/client-fs.ts @@ -0,0 +1,146 @@ +import { extname } from "node:path"; +import { defaultEditOperations, type EditOperations, type EditToolOptions } from "./edit.ts"; +import { defaultReadOperations, type ReadOperations, type ReadToolOptions } from "./read.ts"; +import { defaultWriteOperations, type WriteOperations, type WriteToolOptions } from "./write.ts"; + +/** + * A protocol client's filesystem, reachable over the client's reverse-RPC + * channel. + * + * ACP defines `fs/read_text_file` and `fs/write_text_file` as client-side + * methods: the agent asks, the client answers. Both names say "text" on + * purpose, so the bridge carries text only. Binary payloads (images the read + * tool attaches) stay on the local filesystem, where the bytes are the bytes. + */ +export interface ClientFileSystemBridge { + /** + * Absent when the client did not advertise `fs.readTextFile`. + * + * Optional rather than required because ACP gates the two directions + * independently: a client may offer writes and not reads, or the reverse. + * A missing method means "stay local for this direction", not "fail", so the + * wrappers check before they delegate. + */ + readTextFile?(absolutePath: string): Promise; + writeTextFile?(absolutePath: string, content: string): Promise; +} + +/** + * Extensions the read tool attaches as images. + * + * The tool decides between its text and image branches by asking + * `detectImageMimeType`, which stays local because it reads the file header. + * When that says "image", the following `readFile` must return the real bytes, + * so a bridged read has to stand down for these paths. Duplicating the + * extension set here is deliberate: the alternative is a second round-trip to + * the client to learn something the local header already answered. + */ +const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]); + +/** + * The client filesystem installed by the current protocol host. + * + * Process-scoped on purpose, for the same reason the MCP host overlay is: a + * host that supports exactly one active session at a time (ACP, RPC) installs + * its bridge before that session starts and clears it when the session ends, so + * at most one bridge is ever live. Tools read it at call time rather than + * capturing it at construction time, which is what lets the profile be built + * once per process while the bridge comes and goes with the client's + * capabilities. + */ +let clientFileSystemBridge: ClientFileSystemBridge | undefined; + +export function setClientFileSystemBridge(bridge: ClientFileSystemBridge | undefined): void { + clientFileSystemBridge = bridge; +} + +export function getClientFileSystemBridge(): ClientFileSystemBridge | undefined { + return clientFileSystemBridge; +} + +function isImagePath(absolutePath: string): boolean { + return IMAGE_EXTENSIONS.has(extname(absolutePath).toLowerCase()); +} + +/** + * Route text reads through the client when one is installed. + * + * The local operations stay as the fallback for every case the bridge does not + * cover: an image path, a bridge that throws, and the no-bridge case. A failed + * client read is not silently downgraded either; it propagates so the caller + * sees the client's error rather than a stale local copy that happens to parse. + */ +export function withClientFileSystemRead(operations: T): T { + return { + ...operations, + readFile: async (absolutePath: string) => { + const bridge = clientFileSystemBridge; + if (bridge?.readTextFile === undefined || isImagePath(absolutePath)) { + return operations.readFile(absolutePath); + } + return Buffer.from(await bridge.readTextFile(absolutePath), "utf-8"); + }, + }; +} + +/** Route writes through the client when one is installed. */ +export function withClientFileSystemWrite(operations: T): T { + return { + ...operations, + writeFile: async (absolutePath: string, content: string) => { + const bridge = clientFileSystemBridge; + if (bridge?.writeTextFile === undefined) return operations.writeFile(absolutePath, content); + await bridge.writeTextFile(absolutePath, content); + }, + readFile: operations.readFile + ? async (absolutePath: string) => { + const bridge = clientFileSystemBridge; + if (bridge?.readTextFile === undefined) return operations.readFile?.(absolutePath); + return bridge.readTextFile(absolutePath); + } + : operations.readFile, + }; +} + +/** + * Route edits through the client when one is installed. + * + * The edit tool reads a file, applies a replacement, and writes it back, so both + * halves have to travel the same road. Sending the read through the client and + * the write locally would edit a file the client never saw change, which is the + * exact drift the capability exists to prevent. + */ +export function withClientFileSystemEdit(operations: T): T { + return { + ...operations, + readFile: async (absolutePath: string) => { + const bridge = clientFileSystemBridge; + if (bridge?.readTextFile === undefined || isImagePath(absolutePath)) { + return operations.readFile(absolutePath); + } + return Buffer.from(await bridge.readTextFile(absolutePath), "utf-8"); + }, + writeFile: async (absolutePath: string, content: string) => { + const bridge = clientFileSystemBridge; + if (bridge?.writeTextFile === undefined) return operations.writeFile(absolutePath, content); + await bridge.writeTextFile(absolutePath, content); + }, + }; +} + +/** + * Option-level wrappers, so a caller that passes no operations still gets the + * bridge. Wrapping only a caller-supplied operations object would leave the + * default path, which is the common one, permanently local. + */ +export function withClientFileSystemReadOptions(options: ReadToolOptions | undefined): ReadToolOptions { + return { ...options, operations: withClientFileSystemRead(options?.operations ?? defaultReadOperations) }; +} + +export function withClientFileSystemWriteOptions(options: WriteToolOptions | undefined): WriteToolOptions { + return { ...options, operations: withClientFileSystemWrite(options?.operations ?? defaultWriteOperations) }; +} + +export function withClientFileSystemEditOptions(options: EditToolOptions | undefined): EditToolOptions { + return { ...options, operations: withClientFileSystemEdit(options?.operations ?? defaultEditOperations) }; +} diff --git a/packages/coding-agent/src/core/tools/client-terminal.ts b/packages/coding-agent/src/core/tools/client-terminal.ts new file mode 100644 index 00000000..ea737956 --- /dev/null +++ b/packages/coding-agent/src/core/tools/client-terminal.ts @@ -0,0 +1,220 @@ +import type { ShellConfig } from "../../utils/shell.ts"; +import type { BashOperations } from "./bash.ts"; + +/** What a protocol host must provide for the agent to run commands in its terminal. */ +export interface ClientTerminalHost { + create(params: { + command: string; + args: string[]; + cwd?: string; + env?: Record; + outputByteLimit?: number; + }): Promise<{ terminalId: string }>; + output(terminalId: string): Promise<{ + output: string; + truncated: boolean; + exitStatus?: { exitCode?: number | null; signal?: string | null }; + }>; + waitForExit(terminalId: string): Promise<{ exitCode?: number | null; signal?: string | null }>; + kill(terminalId: string): Promise; + release(terminalId: string): Promise; +} + +/** + * The host installed by the current protocol mode, if any. + * + * Process-scoped for the same reason the filesystem bridge is: a host that + * supports one active session at a time installs it before that session starts + * and clears it when the session ends, so at most one is live. Tools read it at + * call time, which is what lets the profile be built once per process. + */ +let clientTerminalHost: ClientTerminalHost | undefined; + +export function setClientTerminalHost(host: ClientTerminalHost | undefined): void { + clientTerminalHost = host; +} + +export function getClientTerminalHost(): ClientTerminalHost | undefined { + return clientTerminalHost; +} + +/** + * How often `terminal/output` is polled while a command runs. + * + * ACP has no push channel for terminal output, so the agent asks. 120ms is fast + * enough that a progress line appears near when it is printed, and slow enough + * that a long build does not turn into a request storm. + */ +const OUTPUT_POLL_INTERVAL_MS = 120; + +/** + * Output the agent asks the client to retain. + * + * The diff below depends on the client returning the output accumulated so far, + * and the protocol lets a client truncate from the front once this limit is + * passed. Asking for a generous limit keeps that from happening in practice; + * when it does happen anyway the diff stands down rather than re-emitting bytes + * the accumulator already has. + */ +const OUTPUT_BYTE_LIMIT = 16 * 1024 * 1024; + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Raised when the client refused to create a terminal. + * + * A distinct type so the caller can tell "the command never started" from "the + * command started and then something went wrong". Only the first is safe to + * re-run locally. + */ +export class ClientTerminalCreateError extends Error { + constructor(cause: unknown) { + super(`terminal/create failed: ${cause instanceof Error ? cause.message : String(cause)}`); + this.name = "ClientTerminalCreateError"; + } +} + +/** + * `ProcessEnv` is `Record`; ACP wants values that are + * always strings. An unset variable is dropped rather than sent as "undefined", + * which the client would set as the literal string. + */ +function toEnvRecord(env: NodeJS.ProcessEnv): Record { + const record: Record = {}; + for (const [name, value] of Object.entries(env)) { + if (value !== undefined) record[name] = value; + } + return record; +} + +/** + * Run one command in the client's terminal, streaming its output back. + * + * The command arrives as a shell string, because that is what the agent's + * pipeline produces. ACP takes a program plus arguments, so the resolved shell + * config supplies the program and its flags and the string rides as the final + * argument. Using the same config the local path uses is what keeps the two + * paths behaving alike. + */ +async function execInClientTerminal( + host: ClientTerminalHost, + shell: ShellConfig, + command: string, + cwd: string, + options: { onData: (data: Buffer) => void; signal?: AbortSignal; timeout?: number; env?: NodeJS.ProcessEnv }, +): Promise<{ exitCode: number | null }> { + let terminalId: string; + try { + const created = await host.create({ + command: shell.shell, + args: [...shell.args, command], + cwd, + ...(options.env === undefined ? {} : { env: toEnvRecord(options.env) }), + outputByteLimit: OUTPUT_BYTE_LIMIT, + }); + terminalId = created.terminalId; + } catch (error) { + throw new ClientTerminalCreateError(error); + } + + let emitted = ""; + let timedOut = false; + let aborted = false; + const timeoutHandle = + options.timeout === undefined + ? undefined + : setTimeout(() => { + timedOut = true; + void host.kill(terminalId).catch(() => undefined); + }, options.timeout * 1000); + const onAbort = () => { + aborted = true; + void host.kill(terminalId).catch(() => undefined); + }; + if (options.signal) { + if (options.signal.aborted) onAbort(); + else options.signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + for (;;) { + const snapshot = await host.output(terminalId); + // Only the suffix is new. When the client truncated from the front the + // string no longer starts with what was already emitted, and re-emitting + // the whole buffer would duplicate output the accumulator holds. + if (snapshot.output.startsWith(emitted)) { + const fresh = snapshot.output.slice(emitted.length); + if (fresh.length > 0) { + emitted = snapshot.output; + options.onData(Buffer.from(fresh, "utf-8")); + } + } + if (snapshot.exitStatus !== undefined) { + if (aborted) throw new Error("aborted"); + if (timedOut) throw new Error(`timeout:${options.timeout}`); + return { exitCode: snapshot.exitStatus.exitCode ?? null }; + } + // A kill was requested but the client has not reported the exit yet. + // Without this the loop would poll forever, because a killed command + // that never reaches an exit status has nothing else to end it. + if (aborted || timedOut) { + await host.waitForExit(terminalId).catch(() => undefined); + if (aborted) throw new Error("aborted"); + throw new Error(`timeout:${options.timeout}`); + } + await delay(OUTPUT_POLL_INTERVAL_MS); + } + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + if (options.signal) options.signal.removeEventListener("abort", onAbort); + // Release is mandatory once a terminal is no longer needed, and this runs + // on the abort, timeout, and error paths too. + await host.release(terminalId).catch(() => undefined); + } +} + +/** + * Route command execution through the client's terminal when one is installed. + * + * Falls back to the local shell in exactly two cases, both decided before the + * command starts: + * + * - No host is installed, which is every non-ACP run and every ACP client that + * did not advertise `terminal`. + * - The resolved shell feeds the command over stdin. ACP has no channel for + * that, so expressing it is impossible rather than merely awkward. + * + * Once `terminal/create` has been answered the command is running, and a later + * failure is never retried locally: re-running a command that already deleted a + * file or pushed a commit is not a fallback, it is a second side effect. That + * asymmetry with the filesystem bridge is deliberate. A failed read has a local + * file to fall back to; a half-finished command has nothing safe to fall back + * to. + */ +export function withClientTerminalOperations( + fallback: BashOperations, + resolveShell: () => ShellConfig, +): BashOperations { + return { + exec: async (command, cwd, executionOptions) => { + const host = clientTerminalHost; + const shell = resolveShell(); + if (host === undefined || shell.commandTransport === "stdin") { + return fallback.exec(command, cwd, executionOptions); + } + try { + return await execInClientTerminal(host, shell, command, cwd, executionOptions); + } catch (error) { + // Only a create that failed is safe to retry: the command never + // started, so there is no second side effect. Once the terminal + // exists the command is running, and every later failure propagates. + if (error instanceof ClientTerminalCreateError) { + process.stderr.write(`[acp] terminal/create failed, running locally: ${error.message} +`); + return fallback.exec(command, cwd, executionOptions); + } + throw error; + } + }, + }; +} diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 47c6a02f..d0bf5e4d 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -101,7 +101,7 @@ export interface EditOperations { access: (absolutePath: string) => Promise; } -const defaultEditOperations: EditOperations = { +export const defaultEditOperations: EditOperations = { readFile: (path) => fsReadFile(path), writeFile: (path, content) => fsWriteFile(path, content, "utf-8"), access: (path) => fsAccess(path, constants.R_OK | constants.W_OK), diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 924fb512..c88dade2 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -54,7 +54,7 @@ export interface ReadOperations { detectImageMimeType?: (absolutePath: string) => Promise; } -const defaultReadOperations: ReadOperations = { +export const defaultReadOperations: ReadOperations = { readFile: (path) => fsReadFile(path), access: (path) => fsAccess(path, constants.R_OK), detectImageMimeType: detectSupportedImageMimeTypeFromFile, diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 1798c7ec..353c990d 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -36,7 +36,7 @@ export interface WriteOperations { mkdir: (dir: string) => Promise; } -const defaultWriteOperations: WriteOperations = { +export const defaultWriteOperations: WriteOperations = { readFile: (path) => fsReadFile(path), writeFile: (path, content) => fsWriteFile(path, content, "utf-8"), mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => {}), diff --git a/packages/coding-agent/src/features/plan-mode-tools.ts b/packages/coding-agent/src/features/plan-mode-tools.ts index e7e22b57..4e4e204d 100644 --- a/packages/coding-agent/src/features/plan-mode-tools.ts +++ b/packages/coding-agent/src/features/plan-mode-tools.ts @@ -172,19 +172,22 @@ export function registerPlanModeTools(pi: ExtensionAPI, planMode: StepPlanModeCo planContents, planHash, planModeExited: !planMode.isPlanModeActive(), - approvalSource: ctx.mode === "rpc" || !ctx.hasUI ? "external" : "user", + approvalSource: ctx.mode === "rpc" || ctx.mode === "acp" || !ctx.hasUI ? "external" : "user", outcome, ...(feedback === undefined ? {} : { feedback }), }); - if (ctx.mode === "rpc") { - // An rpc child has a dialog bridge (hasUI is true) but no real user + if (ctx.mode === "rpc" || ctx.mode === "acp") { + // An rpc/acp child has a dialog bridge (hasUI is true) but no real user // behind it, so its extension_ui_request select can be auto-cancelled // to "Stay", leaving the child idle in plan mode forever. Exit plan mode // and hand approval to the caller. planMode.exitPlanMode("auto_rpc", ctx); - ctx.ui.notify("rpc child auto-approved plan mode exit — user oversight was not gated", "warning"); + ctx.ui.notify( + "headless protocol child auto-approved plan mode exit — user oversight was not gated", + "warning", + ); return reviewedResult( - `Cannot gate approval in rpc child context. Plan file at ${planFilePath}; caller should approve externally. Plan mode exited and file mutations are re-enabled.\n\nPlan contents:\n${planContents}`, + `Cannot gate approval in a headless protocol child context. Plan file at ${planFilePath}; caller should approve externally. Plan mode exited and file mutations are re-enabled.\n\nPlan contents:\n${planContents}`, reviewed("external_approval_required"), ); } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3c5f0415..96e41b6f 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -528,6 +528,7 @@ export { type RpcExtensionUIResponse, type RpcResponse, type RpcSessionState, + runAcpMode, runPrintMode, runRpcMode, } from "./modes/index.ts"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index b18c056c..dcedd60d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -89,7 +89,7 @@ import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/tru import { builtInExtensions } from "./features/index.ts"; import { childSessionHeader } from "./features/step-subagent.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; -import { runPrintMode, runRpcMode } from "./modes/index.ts"; +import { runAcpMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import type { InteractiveModeOptions, StartupTuiPathOptions, StartupUiHooks } from "./modes/interactive-contract.ts"; import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { @@ -138,6 +138,17 @@ export async function readPipedStdin(): Promise { }); } +/** + * Whether a mode reads piped stdin as the initial prompt. + * + * RPC and ACP own stdin as their JSON-RPC transport, so they must never drain + * it. An ACP client keeps the pipe open for the whole session, and a read that + * waits for `end` would hang before the mode ever starts. + */ +export function modeReadsPipedStdin(appMode: AppMode): boolean { + return appMode !== "rpc" && appMode !== "acp"; +} + function reportDiagnostics(diagnostics: readonly AgentSessionRuntimeDiagnostic[]): void { for (const diagnostic of diagnostics) { const color = diagnostic.type === "error" ? chalk.red : diagnostic.type === "warning" ? chalk.yellow : chalk.dim; @@ -152,8 +163,8 @@ export function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: b // headless mode so normal piped-prompt handling never consumes the stream. return "rpc"; } - if (parsed.mode === "rpc") { - return "rpc"; + if (parsed.mode === "rpc" || parsed.mode === "acp") { + return parsed.mode; } if (parsed.mode === "json") { return "json"; @@ -164,7 +175,7 @@ export function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: b return "interactive"; } -export function toPrintOutputMode(appMode: AppMode): Exclude { +export function toPrintOutputMode(appMode: AppMode): Exclude { return appMode === "json" ? "json" : "text"; } @@ -1028,8 +1039,8 @@ export async function prepareMain(args: string[], options?: MainOptions): Promis 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`)); return { kind: "completed", exitCode: 1 }; } @@ -1373,9 +1384,12 @@ export async function prepareMain(args: string[], options?: MainOptions): Promis return { kind: "completed", exitCode: 0 }; } - // Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPC + // Read piped stdin content (if any) - skip for RPC and ACP, which use stdin + // as their JSON-RPC transport. Draining it here would swallow the protocol + // stream, and on an ACP session whose client keeps the pipe open the read + // never ends, so the mode would never start. let stdinContent: string | undefined; - if (appMode !== "rpc") { + if (modeReadsPipedStdin(appMode)) { stdinContent = await readPipedStdin(); if (stdinContent !== undefined && appMode === "interactive") { appMode = "print"; @@ -1426,8 +1440,8 @@ export async function prepareMain(args: string[], options?: MainOptions): Promis return { kind: "completed", drainNaturally: true }; } - // RPC refreshes catalogs here in the background; interactive mode starts its refresh after TUI initialization. - if (!options?.disableBackgroundServices && appMode === "rpc") { + // RPC and ACP refresh catalogs here in the background; interactive mode starts its refresh after TUI initialization. + if (!options?.disableBackgroundServices && (appMode === "rpc" || appMode === "acp")) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15_000); void modelRuntime @@ -1470,6 +1484,8 @@ async function dispatchAppMode( const { appMode, runtimeHost } = prep; if (appMode === "rpc") { await runRpcMode(runtimeHost); + } else if (appMode === "acp") { + await runAcpMode(runtimeHost); } else if (appMode === "interactive") { // The interactive TUI moved to @step-harness/cli (S4-0). coding-agent's own // main() no longer dispatches it; product shells construct InteractiveMode diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts new file mode 100644 index 00000000..491e8b5f --- /dev/null +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -0,0 +1,1719 @@ +/** + * ACP mode: native stdio JSON-RPC for Agent Client Protocol v1. + * + * This mode exposes the baseline ACP client surface over newline-delimited + * JSON-RPC 2.0 on stdin/stdout. It reuses the same `AgentSessionRuntimeHost` + * as the interactive TUI and RPC mode, so session persistence, extensions, + * compaction, and tool execution behave identically. Only the wire format is + * different. + * + * Supported client-to-agent methods: + * - `initialize`, `authenticate`, `logout` + * - `session/new`, `session/load`, `session/resume`, `session/list`, + * `session/delete`, `session/close` + * - `session/prompt`, `session/cancel` + * - `session/set_mode`, `session/set_config_option` + * + * Agent-to-client traffic uses `session/update` notifications, + * `session/request_permission` requests for tool approval, and + * `elicitation/create` requests when an extension needs free-form input. + * + * Agent-to-client traffic uses `session/update` notifications, + * `session/request_permission` requests for tool approval, and + * `elicitation/create` requests when an extension needs free-form input. + * + * The client-side surfaces work in opposite directions. `fs/*` is bridged into + * this mode's file tools, because the client's read is a superset of a local one + * (it sees unsaved editor state) and its writes let the client track what the + * agent changed. `terminal/*` is forwarded but not wired into the bash tool, + * because ACP takes a program plus arguments where the agent's pipeline is a + * shell string, and re-routing every command through a client shell is a product + * decision rather than a protocol detail. See docs/acp.md. + */ + +import * as crypto from "node:crypto"; +import { unlink } from "node:fs/promises"; +import type { AssistantMessage, Model } from "@step-harness/providers"; +import { VERSION } from "../../config.ts"; +import type { AgentSessionEvent } from "../../core/agent-session.ts"; +import type { AgentSessionRuntimeHost } from "../../core/agent-session-runtime.ts"; +import type { + ExtensionUIContext, + ExtensionUIDialogOptions, + ExtensionWidgetOptions, + WorkingIndicatorOptions, +} from "../../core/extensions/index.ts"; +import { + flushRawStdout, + takeOverStdout, + waitForRawStdoutBackpressure, + writeRawStdout, +} from "../../core/output-guard.ts"; +import { loadEntriesFromFile, type SessionEntry, SessionManager } from "../../core/session-manager.ts"; +import { setClientFileSystemBridge } from "../../core/tools/client-fs.ts"; +import { type ClientTerminalHost, setClientTerminalHost } from "../../core/tools/client-terminal.ts"; +import { STEP_PROVIDER_ID } from "../../features/step-provider/index.ts"; +import { getStepAuthPath, logoutStepCredentials } from "../../step/auth.ts"; +import { readStepLoginCredential, writeStepLoginCredential } from "../../step/login-flow.ts"; +import { clearStepLoginState } from "../../step/login-state.ts"; +import { setHostMcpServerOverlay } from "../../step/mcp.ts"; +import { resolveStepLoginProfiles } from "../../step/onboarding.ts"; +import { getStepPermissionPreset, STEP_PERMISSION_PRESETS } from "../../step/permissions.ts"; +import { type Theme, theme } from "../../theme/theme.ts"; +import { resolvePath } from "../../utils/paths.ts"; +import { killTrackedDetachedChildren } from "../../utils/shell.ts"; +import { attachJsonlLineReader } from "../rpc/jsonl.ts"; +import type { + AuthMethod, + CloseSessionParams, + ElicitationCreateParams, + ElicitationResult, + ForkSessionParams, + JsonRpcId, + JsonRpcRequest, + JsonRpcResponseObject, + ListSessionsParams, + PromptParams, + ReadTextFileResult, + RequestPermissionResult, + ResumeSessionParams, + SessionConfigOption, + SessionModeState, + SessionNotificationParams, + SessionUpdate, + SetSessionConfigOptionParams, + SetSessionModelParams, + SetSessionModeParams, +} from "./acp-types.ts"; +import { ACP_JSON_RPC_ERRORS, ACP_PROTOCOL_VERSION } from "./acp-types.ts"; +import { + convertAcpMcpServers, + createErrorResponse, + createNotification, + createResponse, + extractTurnError, + normalizePrompt, + parseElicitationResult, + parseJsonRpcLine, + parseRequestPermissionResult, + serializeJsonLine, + toAcpSessionListEntries, + toSessionNotification, + validateAuthenticateParams, + validateCloseSessionParams, + validateDeleteSessionParams, + validateForkSessionParams, + validateInitializeParams, + validateListSessionsParams, + validateLoadSessionParams, + validateNewSessionParams, + validatePromptParams, + validateResumeSessionParams, + validateSetSessionConfigOptionParams, + validateSetSessionModelParams, + validateSetSessionModeParams, +} from "./acp-wire.ts"; +import { ClientTerminalRegistry } from "./client-terminal.ts"; + +const PERMISSION_ALLOW_OPTION_ID = "allow_once"; +const PERMISSION_ALLOW_ALWAYS_OPTION_ID = "allow_always"; +const PERMISSION_REJECT_OPTION_ID = "reject_once"; +const PERMISSION_REJECT_ALWAYS_OPTION_ID = "reject_always"; + +/** Status key the step extension uses to publish the active permission tier. */ +const PERMISSION_STATUS_KEY = "step-permission"; + +/** Prefix of that status text; the remainder is the preset label. */ +const PERMISSION_STATUS_PREFIX = "Mode: "; + +/** Auth method the client runs itself as an interactive `step login`. */ +const STEP_TERMINAL_AUTH_METHOD_ID = "step_login"; + +/** Auth method this agent completes through `elicitation/create`. */ +const STEP_API_KEY_AUTH_METHOD_ID = "step_api_key"; + +export async function runAcpMode(runtimeHost: AgentSessionRuntimeHost): Promise { + takeOverStdout(); + let session = runtimeHost.session; + let unsubscribe: (() => void) | undefined; + let unsubscribeBackpressure: (() => void) | undefined; + + let initialized = false; + let shuttingDown = false; + /** + * Read from `initialize`. A client that does not advertise form elicitation + * keeps the allow/reject fallback for `select()` and gets no `input()`. + */ + let clientSupportsFormElicitation = false; + /** Whether the client can run `step login` in its own terminal. */ + let clientSupportsTerminalAuth = false; + /** + * Which directions of the client's filesystem the client offered. + * + * ACP gates read and write independently, so a client that offers only one + * gets a bridge with only that direction; the other stays local rather than + * failing. + */ + let clientSupportsFsRead = false; + let clientSupportsFsWrite = false; + /** Whether the client can host terminals this agent asks it to create. */ + let clientSupportsTerminal = false; + /** Terminals handed out by `terminal/create`, released on switch and exit. */ + let terminalRegistry: ClientTerminalRegistry | undefined; + /** + * Last permission label the step extension published through `setStatus`. + * The extension owns the policy, so this mode reports what it published + * rather than keeping a second copy that could drift. + */ + let publishedPermissionLabel: string | undefined; + let detachInput = () => {}; + const signalCleanupHandlers: Array<() => void> = []; + + /** Sessions returned per `session/list` page; a client that wants them all follows `nextCursor`. */ + const SESSION_LIST_PAGE_SIZE = 100; + + const activePromptIds = new Set(); + const cancelledPromptIds = new Set(); + /** + * Error message of the turn currently in flight, if it failed. + * + * Set from `agent_end`, because a failed model call does not reject + * `session.prompt()`; it is recorded on the assistant message. The engine + * allows one turn at a time per session (a prompt sent while streaming is + * rejected or queued), so one slot is enough. + */ + let inFlightTurnError: string | undefined; + + /** + * Every request this agent sends to the client, permission or elicitation. + * + * Both kinds block a turn until the client answers, both are answered through + * the same `handleClientResponse` path, and both must be settled on shutdown + * so a pending turn is never left hanging. They differ only in how the + * response is parsed, which is the caller's job. + */ + const pendingClientRequests = new Map void }>(); + + /** + * Send one request to the client and wait for its response. + * + * There is no timeout on purpose: a permission dialog or a form may sit open + * for as long as the user needs. `shutdown` settles everything still pending, + * which is the only way this promise is abandoned. + */ + const requestFromClient = (method: string, params: unknown): Promise => { + const requestId = crypto.randomUUID(); + return new Promise((resolve) => { + pendingClientRequests.set(requestId, { resolve }); + output({ jsonrpc: "2.0", id: requestId, method, params }); + }); + }; + + /** Settle a pending client request, whether the client answered or gave up. */ + const settleClientRequest = (requestId: string, result: unknown): void => { + const pending = pendingClientRequests.get(requestId); + if (!pending) return; + pendingClientRequests.delete(requestId); + pending.resolve(result); + }; + + /** + * Terminals this agent asked the client to create. + * + * Created here rather than per session because the registry is a forwarder: + * it holds no process, only which ids were handed out and to which session. + */ + terminalRegistry = new ClientTerminalRegistry(requestFromClient, () => session.sessionId); + + /** + * Install the client filesystem the file tools read and write through. + * + * Only the directions the client advertised are installed. A missing method + * is not an error: ACP gates read and write independently, so a client that + * offers one keeps local behavior for the other instead of failing it. + * + * A failed client read falls back to the local file rather than propagating. + * The client said it implements the method, but a client that advertises and + * then answers `methodNotFound` would otherwise turn every read into a hard + * failure for a file that is sitting right there on the shared filesystem. + */ + const installClientFileSystemBridge = (): void => { + if (!clientSupportsFsRead && !clientSupportsFsWrite) { + setClientFileSystemBridge(undefined); + return; + } + setClientFileSystemBridge({ + ...(clientSupportsFsRead + ? { + readTextFile: async (absolutePath: string): Promise => { + try { + const result = (await requestFromClient("fs/read_text_file", { + sessionId: session.sessionId, + path: absolutePath, + })) as ReadTextFileResult; + if (typeof result?.content !== "string") { + throw new Error("client returned no content"); + } + return result.content; + } catch (error) { + process.stderr.write( + `[acp] fs/read_text_file failed for ${absolutePath}, reading locally: ${String(error)} +`, + ); + const { readFile } = await import("node:fs/promises"); + return (await readFile(absolutePath)).toString("utf-8"); + } + }, + } + : {}), + ...(clientSupportsFsWrite + ? { + writeTextFile: async (absolutePath: string, content: string): Promise => { + try { + await requestFromClient("fs/write_text_file", { + sessionId: session.sessionId, + path: absolutePath, + content, + }); + } catch (error) { + // A write has no safe local fallback: writing the same bytes + // twice, once where the client can see it and once where it + // cannot, is worse than one visible failure. + process.stderr.write(`[acp] fs/write_text_file failed for ${absolutePath}: ${String(error)} +`); + throw error; + } + }, + } + : {}), + }); + }; + + /** + * Install the client terminal the bash tool runs commands in. + * + * Installed only when the client advertised `terminal`, so a client that did + * not ask for it keeps purely local execution. The registry already forwards + * to the client; this adapts it to the shape the tool consumes. + */ + const installClientTerminalHost = (): void => { + if (!clientSupportsTerminal || terminalRegistry === undefined) { + setClientTerminalHost(undefined); + return; + } + const registry = terminalRegistry; + const host: ClientTerminalHost = { + create: async (params) => { + const record = await registry.create({ + command: params.command, + ...(params.args === undefined ? {} : { args: params.args }), + ...(params.cwd === undefined ? {} : { cwd: params.cwd }), + ...(params.env === undefined + ? {} + : { env: Object.entries(params.env).map(([name, value]) => ({ name, value })) }), + ...(params.outputByteLimit === undefined ? {} : { outputByteLimit: params.outputByteLimit }), + }); + return { terminalId: record.terminalId }; + }, + output: (terminalId) => registry.output(terminalId), + waitForExit: (terminalId) => registry.waitForExit(terminalId), + kill: (terminalId) => registry.kill(terminalId), + release: (terminalId) => registry.release(terminalId), + }; + setClientTerminalHost(host); + }; + + /** + * Drop everything scoped to the session that is about to stop being active. + * + * Two kinds of state are session-scoped, and both must go on a switch: + * + * - Terminals, because a terminal belongs to the session that created it, so + * a switch makes the whole set unusable. The spec makes release mandatory + * once a terminal is no longer needed, and leaking them would hold the + * client's terminals for the life of the client process. + * - The "always allow" and "always reject" answers, because ACP scopes them to + * the session. Carrying an allow across a session switch would silently + * approve tool calls in a session whose user never consented to them. + */ + const releaseSessionScopedClientState = async (): Promise => { + sessionAlwaysAllows = false; + sessionAlwaysRejects = false; + await terminalRegistry?.releaseAll(); + }; + + const output = (obj: unknown) => { + writeRawStdout(serializeJsonLine(obj)); + }; + + const sendNotification = (method: string, params: unknown) => { + output(createNotification(method, params)); + }; + + const sendResponse = (id: JsonRpcId | null, result: unknown) => { + output(createResponse(id, result)); + }; + + const sendError = (id: JsonRpcId | null, code: number, message: string, data?: unknown) => { + output(createErrorResponse(id, code, message, data)); + }; + + /** + * Whether the user has already answered "always" for this session. + * + * `allow_always` and `reject_always` are session-scoped in ACP: the client + * picks one and the agent stops asking for the rest of the session. That is + * the whole scope the protocol offers, so it is honoured as a flag here rather + * than as a per-tool rule the protocol has no way to express. + */ + let sessionAlwaysAllows = false; + let sessionAlwaysRejects = false; + + const sendPermissionRequest = async (title: string, message: string): Promise => { + // An "always" answer from earlier in this session settles every later + // request without a round-trip. Rejecting wins when both are set, because + // a client that somehow selected both should not be read as consent. + if (sessionAlwaysRejects) return { outcome: { outcome: "selected", optionId: PERMISSION_REJECT_OPTION_ID } }; + if (sessionAlwaysAllows) return { outcome: { outcome: "selected", optionId: PERMISSION_ALLOW_OPTION_ID } }; + const response = await requestFromClient("session/request_permission", { + sessionId: session.sessionId, + toolCall: { + toolCallId: crypto.randomUUID(), + title, + status: "pending", + content: [{ type: "content", content: { type: "text", text: message } }], + }, + options: [ + { optionId: PERMISSION_ALLOW_OPTION_ID, name: "Allow once", kind: "allow_once" }, + { optionId: PERMISSION_ALLOW_ALWAYS_OPTION_ID, name: "Allow for this session", kind: "allow_always" }, + { optionId: PERMISSION_REJECT_OPTION_ID, name: "Reject once", kind: "reject_once" }, + { optionId: PERMISSION_REJECT_ALWAYS_OPTION_ID, name: "Reject for this session", kind: "reject_always" }, + ], + }); + // A response that does not parse is a client that answered something else; + // treating it as a cancellation is the safe reading for an approval. + const result = parseRequestPermissionResult(response) ?? { outcome: { outcome: "cancelled" } }; + if (result.outcome.outcome === "selected") { + if (result.outcome.optionId === PERMISSION_ALLOW_ALWAYS_OPTION_ID) sessionAlwaysAllows = true; + if (result.outcome.optionId === PERMISSION_REJECT_ALWAYS_OPTION_ID) sessionAlwaysRejects = true; + } + return result; + }; + + const isPermissionAllowed = (result: RequestPermissionResult): boolean => { + if (result.outcome.outcome !== "selected") return false; + return ( + result.outcome.optionId === PERMISSION_ALLOW_OPTION_ID || + result.outcome.optionId === PERMISSION_ALLOW_ALWAYS_OPTION_ID + ); + }; + + /** + * Ask the user for structured input through `elicitation/create`. + * + * Only the form mode is used. A client that declines or cancels, or that + * answers with a payload this mode cannot read, yields `undefined` so the + * caller behaves exactly as it does for a dismissed dialog. + */ + const elicitForm = async (input: { + prompt: string; + properties: Record; + required?: string[]; + }): Promise | undefined> => { + const params: ElicitationCreateParams = { + sessionId: session.sessionId, + mode: "form", + prompt: input.prompt, + requestedSchema: { + type: "object", + properties: input.properties, + ...(input.required === undefined ? {} : { required: input.required }), + }, + }; + const response = await requestFromClient("elicitation/create", params); + const result: ElicitationResult | undefined = parseElicitationResult(response); + if (!result || result.action !== "accept") return undefined; + return result.content ?? {}; + }; + + /** Create an extension UI context backed by ACP permission requests. */ + const createExtensionUIContext = (): ExtensionUIContext => { + const requestApproval = async ( + opts: ExtensionUIDialogOptions | undefined, + title: string, + message: string, + ): Promise => { + if (opts?.signal?.aborted) return false; + return isPermissionAllowed(await sendPermissionRequest(title, message)); + }; + + /** + * Offer a fixed set of choices. + * + * With form elicitation the user picks the real option, so `select()` + * stops guessing which entry was chosen. Without it the only surface ACP + * guarantees is a yes/no approval, which cannot name an option, so the + * fallback returns the first entry when approved and says so below. + */ + const selectWithElicitation = async ( + title: string, + options: string[], + opts: ExtensionUIDialogOptions | undefined, + ): Promise => { + if (opts?.signal?.aborted || options.length === 0) return undefined; + const content = await elicitForm({ + prompt: title, + properties: { choice: { type: "string", title, enum: options } }, + required: ["choice"], + }); + const choice = content?.choice; + return typeof choice === "string" && options.includes(choice) ? choice : undefined; + }; + + return { + select: (title, options, opts) => + clientSupportsFormElicitation + ? selectWithElicitation(title, options, opts) + : requestApproval(opts, title, options.join("\n")).then((approved) => + approved ? options[0] : undefined, + ), + + confirm: (title, message, opts) => requestApproval(opts, title, message), + + // Free-form text needs elicitation; without it there is no honest + // answer, so the dialog resolves as dismissed. + input: (title, _placeholder, opts) => { + if (!clientSupportsFormElicitation || opts?.signal?.aborted) { + return Promise.resolve(undefined); + } + return elicitForm({ + prompt: title, + properties: { value: { type: "string", title } }, + required: ["value"], + }).then((content) => { + const value = content?.value; + return typeof value === "string" ? value : undefined; + }); + }, + + notify(message: string, type?: "info" | "warning" | "error"): void { + process.stderr.write(`[acp ${type ?? "info"}] ${message}\n`); + }, + + onTerminalInput(): () => void { + return () => {}; + }, + + setStatus(key: string, text: string | undefined): void { + // The step extension publishes the permission tier here; keeping the + // last label lets session/set_mode report the mode actually in force, + // including one changed mid-session. + if (key === PERMISSION_STATUS_KEY) { + publishedPermissionLabel = text; + } + }, + + setWorkingMessage(_message?: string): void {}, + + setWorkingVisible(_visible: boolean): void {}, + + setWorkingIndicator(_options?: WorkingIndicatorOptions): void {}, + + setHiddenThinkingLabel(_label?: string): void {}, + + setWidget(_key: string, _content: unknown, _options?: ExtensionWidgetOptions): void {}, + + setFooter(_factory: unknown): void {}, + + setHeader(_factory: unknown): void {}, + + setTitle(title: string): void { + process.stderr.write(`[acp title] ${title}\n`); + }, + + async custom() { + return undefined as never; + }, + + pasteToEditor(text: string): void { + this.setEditorText(text); + }, + + setEditorText(_text: string): void {}, + + getEditorText(): string { + return ""; + }, + + async editor(): Promise { + return undefined; + }, + + addAutocompleteProvider(): void {}, + + setEditorComponent(): void {}, + + getEditorComponent() { + return undefined; + }, + + get theme() { + return theme; + }, + + getAllThemes() { + return []; + }, + + getTheme(_name: string) { + return undefined; + }, + + setTheme(_theme: string | Theme) { + return { success: false, error: "Theme switching not supported in ACP mode" }; + }, + + getToolsExpanded() { + return false; + }, + + setToolsExpanded(_expanded: boolean): void {}, + }; + }; + + runtimeHost.setRebindSession(async () => { + await rebindSession(); + }); + + const rebindSession = async (): Promise => { + const nextSession = runtimeHost.session; + unsubscribe?.(); + unsubscribeBackpressure?.(); + session = nextSession; + unsubscribe = session.subscribe((event: AgentSessionEvent) => { + if (!initialized) return; + if (event.type === "agent_end") { + inFlightTurnError = extractTurnError(event.messages); + } + const notification: SessionNotificationParams | null = toSessionNotification(session.sessionId, event); + if (notification) { + sendNotification("session/update", notification); + } + }); + unsubscribeBackpressure = session.agent.subscribe(async () => { + await waitForRawStdoutBackpressure(); + }); + await session.bindExtensions({ + uiContext: createExtensionUIContext(), + mode: "acp", + commandContextActions: { + waitForIdle: () => session.waitForIdle(), + newSession: async (options) => runtimeHost.newSession(options), + fork: async (entryId, forkOptions) => { + const result = await runtimeHost.fork(entryId, forkOptions); + return { cancelled: result.cancelled }; + }, + navigateTree: async (targetId, options) => { + const result = await session.navigateTree(targetId, { + summarize: options?.summarize, + customInstructions: options?.customInstructions, + replaceInstructions: options?.replaceInstructions, + label: options?.label, + }); + return { cancelled: result.cancelled }; + }, + switchSession: async (sessionPath, options) => { + return runtimeHost.switchSession(sessionPath, options); + }, + reload: async () => { + await session.reload(); + }, + }, + shutdownHandler: () => { + void shutdown(); + }, + onError: (err) => { + process.stderr.write(`[acp extension error] ${err.error}\n`); + }, + }); + }; + + const registerSignalHandlers = (): void => { + const signals: NodeJS.Signals[] = ["SIGTERM", "SIGINT"]; + if (process.platform !== "win32") { + signals.push("SIGHUP"); + } + + for (const signal of signals) { + const handler = () => { + killTrackedDetachedChildren(); + void shutdown(signal === "SIGHUP" ? 129 : signal === "SIGINT" ? 130 : 143, signal); + }; + process.on(signal, handler); + signalCleanupHandlers.push(() => process.off(signal, handler)); + } + }; + + await rebindSession(); + registerSignalHandlers(); + + const handlePrompt = async (id: JsonRpcId, params: PromptParams): Promise => { + if (params.sessionId !== session.sessionId) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown sessionId: ${params.sessionId}`); + return; + } + const { text, images } = normalizePrompt(params.prompt); + activePromptIds.add(id); + inFlightTurnError = undefined; + try { + await session.prompt(text, { images, source: "acp" }); + const turnError = inFlightTurnError; + inFlightTurnError = undefined; + if (turnError !== undefined && !cancelledPromptIds.has(id)) { + // The turn produced nothing. ACP v1 has no error stop reason, so the + // error channel is the only honest reply; `end_turn` would claim an + // answer that does not exist. + sendError(id, ACP_JSON_RPC_ERRORS.internal, turnError); + return; + } + sendResponse(id, { stopReason: cancelledPromptIds.has(id) ? "cancelled" : "end_turn" }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (cancelledPromptIds.has(id)) { + sendResponse(id, { stopReason: "cancelled" }); + } else { + sendError(id, ACP_JSON_RPC_ERRORS.internal, message); + } + } finally { + activePromptIds.delete(id); + cancelledPromptIds.delete(id); + } + }; + + /** + * Install the client's MCP servers for the session about to become active. + * + * Must run before `newSession()` / `switchSession()`, because discovery reads + * the overlay once during the new session's `session_start`. A client that + * sends no servers clears the overlay, so a previous client's servers do not + * leak into a session that never asked for them. + */ + const applyClientMcpServers = (mcpServers: unknown): { accepted: number; rejected: string[] } => { + const { declarations, rejected } = convertAcpMcpServers(mcpServers); + setHostMcpServerOverlay(Object.keys(declarations).length > 0 ? declarations : undefined); + return { accepted: Object.keys(declarations).length, rejected }; + }; + + /** + * Report workspace roots beyond `cwd` instead of dropping them. + * + * A stdio session's tools resolve every path against one working directory, so + * extra roots cannot be honoured without re-plumbing path resolution through + * every tool. Saying so on stderr keeps the gap visible; staying silent would + * leave the client believing it had granted workspace roots the agent cannot + * reach. All three session-entry methods that accept the field route through + * here, so the report is identical whichever one a client calls. + */ + const reportUnhonoredAdditionalDirectories = (additionalDirectories: readonly string[]): void => { + if (additionalDirectories.length === 0) return; + process.stderr.write( + `[acp] additionalDirectories is not honored; the session is scoped to ${runtimeHost.cwd}: ${additionalDirectories.join(", ")}\n`, + ); + }; + + const handleNewSession = async ( + id: JsonRpcId, + cwd: string, + mcpServers: unknown, + additionalDirectories: readonly string[], + ): Promise => { + if (resolvePath(cwd) !== resolvePath(runtimeHost.cwd)) { + sendError( + id, + ACP_JSON_RPC_ERRORS.invalidParams, + `cwd must match the agent working directory: ${runtimeHost.cwd}`, + ); + return; + } + reportUnhonoredAdditionalDirectories(additionalDirectories); + const mcp = applyClientMcpServers(mcpServers); + if (mcp.rejected.length > 0) { + process.stderr.write(`[acp] ignoring MCP servers with unsupported transports: ${mcp.rejected.join(", ")}\n`); + } + const missingAuth = missingAuthError(); + if (missingAuth !== undefined) { + sendError(id, ACP_JSON_RPC_ERRORS.authRequired, missingAuth); + return; + } + await releaseSessionScopedClientState(); + const result = await runtimeHost.newSession(); + if (result.cancelled) { + // The engine cancels a session switch when a `session_before_switch` + // handler declines it, which in practice is the setup flow finding no + // model to select. An ACP client has no way to act on a bare + // "cancelled", and no way to run the interactive setup that would fix + // it, so say what to configure instead. + sendError( + id, + ACP_JSON_RPC_ERRORS.unavailable, + "Session creation was cancelled. ACP mode has no setup prompt, so this usually means no model is configured: run `step login` or add a provider to config.toml, then retry.", + ); + return; + } + sendResponse(id, { + sessionId: session.sessionId, + modes: buildSessionModeState(), + configOptions: buildConfigOptions(), + }); + }; + + const handleLoadSession = async ( + id: JsonRpcId, + params: { sessionId: string; cwd: string; mcpServers: unknown; additionalDirectories: readonly string[] }, + ): Promise => { + let info = (await SessionManager.list(params.cwd)).find((candidate) => candidate.id === params.sessionId); + if (!info) { + info = (await SessionManager.listAll()).find((candidate) => candidate.id === params.sessionId); + } + if (!info) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Session not found: ${params.sessionId}`); + return; + } + if (info.cwd && resolvePath(info.cwd) !== resolvePath(params.cwd)) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Session cwd does not match: ${info.cwd}`); + return; + } + reportUnhonoredAdditionalDirectories(params.additionalDirectories); + const mcp = applyClientMcpServers(params.mcpServers); + if (mcp.rejected.length > 0) { + process.stderr.write(`[acp] ignoring MCP servers with unsupported transports: ${mcp.rejected.join(", ")}\n`); + } + const missingAuth = missingAuthError(); + if (missingAuth !== undefined) { + sendError(id, ACP_JSON_RPC_ERRORS.authRequired, missingAuth); + return; + } + await releaseSessionScopedClientState(); + await runtimeHost.switchSession(info.path, { cwdOverride: params.cwd }); + // Loading is the entry point the protocol requires to replay, so the + // history is streamed before the response that says the session is ready. + replayStoredSession(info.path); + sendResponse(id, { modes: buildSessionModeState(), configOptions: buildConfigOptions() }); + }; + + /** + * Turn stored session entries into v1 `session/update` chunks. + * + * v1 has no whole-message update and no message ids, so a replayed message + * arrives as one chunk per content part in stored order. That is lossy for + * interleaved thinking and text (the client sees them sequentially rather + * than interleaved), which is the protocol's shape, not a choice here. + */ + const buildReplayUpdates = (entries: SessionEntry[]): SessionUpdate[] => { + const updates: SessionUpdate[] = []; + for (const entry of entries) { + if (entry.type !== "message") continue; + const message = entry.message; + const isUser = message.role === "user"; + const isAssistant = message.role === "assistant"; + if (!isUser && !isAssistant) continue; + // Older sessions store a bare string instead of a content array. + const parts: ReadonlyArray = + typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content; + for (const part of parts) { + // v1 has no image chunk kind, so image parts are surfaced as text placeholders. + if (part.type === "image") { + const source = (part as { source?: { type?: string; media_type?: string } }).source; + const label = source?.type === "base64" ? (source.media_type ?? "image") : "image"; + updates.push({ + sessionUpdate: isUser ? "user_message_chunk" : "agent_message_chunk", + content: { type: "text", text: `[${label} omitted: ACP v1 has no image chunk]` }, + }); + continue; + } + if (part.type === "text" && part.text.length > 0) { + updates.push({ + sessionUpdate: isUser ? "user_message_chunk" : "agent_message_chunk", + content: { type: "text", text: part.text }, + }); + continue; + } + if (isAssistant && part.type === "thinking" && part.thinking.length > 0) { + updates.push({ + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: part.thinking }, + }); + } + } + } + return updates; + }; + + /** + * Read a `session/list` cursor as the index the next page starts at. + * + * The cursor is opaque to the client, so it only ever carries what this mode + * put there. Anything else is rejected rather than restarted from the first + * page, which would replay sessions the client already holds. + */ + const readSessionListCursor = (cursor: string | undefined): number | undefined => { + if (cursor === undefined) return 0; + if (!/^\d+$/.test(cursor)) return undefined; + const offset = Number(cursor); + return Number.isSafeInteger(offset) ? offset : undefined; + }; + + /** + * Stream a stored session's history back to the client. + * + * The protocol splits the two entry points: `session/load` MUST replay the + * conversation and `session/resume` MUST NOT, so exactly one of them calls + * this. Replay goes out before the response that reports the session ready, + * which is what lets a client render the transcript it just asked for. + */ + const replayStoredSession = (sessionPath: string): void => { + const entries = loadEntriesFromFile(sessionPath).filter( + (entry): entry is SessionEntry => entry.type !== "session", + ); + for (const update of buildReplayUpdates(entries)) { + sendNotification("session/update", { sessionId: session.sessionId, update }); + } + }; + + const handleListSessions = async (id: JsonRpcId, params: ListSessionsParams): Promise => { + const offset = readSessionListCursor(params.cursor); + if (offset === undefined) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Invalid cursor: ${String(params.cursor)}`); + return; + } + const sessions = + params.cwd === undefined ? await SessionManager.listAll() : await SessionManager.list(params.cwd); + // Both listings are ordered newest first, so an index is a stable page + // boundary as long as the store does not change mid-pagination. + const page = sessions.slice(offset, offset + SESSION_LIST_PAGE_SIZE); + const nextOffset = offset + page.length; + sendResponse(id, { + sessions: toAcpSessionListEntries(page), + // Omitted on the last page, which is how the protocol says "no more". + ...(nextOffset < sessions.length ? { nextCursor: String(nextOffset) } : {}), + }); + }; + + const handleResumeSession = async (id: JsonRpcId, params: ResumeSessionParams): Promise => { + let info = (await SessionManager.list(params.cwd)).find((candidate) => candidate.id === params.sessionId); + if (!info) { + info = (await SessionManager.listAll()).find((candidate) => candidate.id === params.sessionId); + } + if (!info) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Session not found: ${params.sessionId}`); + return; + } + if (info.cwd && resolvePath(info.cwd) !== resolvePath(params.cwd)) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Session cwd does not match: ${info.cwd}`); + return; + } + reportUnhonoredAdditionalDirectories(params.additionalDirectories); + const missingAuth = missingAuthError(); + if (missingAuth !== undefined) { + sendError(id, ACP_JSON_RPC_ERRORS.authRequired, missingAuth); + return; + } + applyClientMcpServers(params.mcpServers); + await releaseSessionScopedClientState(); + await runtimeHost.switchSession(info.path, { cwdOverride: params.cwd }); + + // No replay here, and that is the protocol's rule rather than a choice: + // resume reattaches to a session the client is already showing, so + // streaming the history again would duplicate every message on screen. + // `session/load` is the entry point that replays. + sendResponse(id, { modes: buildSessionModeState(), configOptions: buildConfigOptions() }); + }; + + const handleDeleteSession = async (id: JsonRpcId, sessionId: string): Promise => { + if (sessionId === session.sessionId) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, "Cannot delete the active session"); + return; + } + const info = (await SessionManager.listAll()).find((candidate) => candidate.id === sessionId); + if (!info) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Session not found: ${sessionId}`); + return; + } + try { + await unlink(info.path); + } catch (error: unknown) { + sendError(id, ACP_JSON_RPC_ERRORS.internal, `Failed to delete session: ${String(error)}`); + return; + } + sendResponse(id, {}); + }; + + const handleCloseSession = async (id: JsonRpcId, params: CloseSessionParams): Promise => { + if (params.sessionId !== session.sessionId) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown sessionId: ${params.sessionId}`); + return; + } + // Closing cancels the foreground turn but keeps the session on disk, so a + // later session/load or session/resume can pick it back up. + for (const promptId of activePromptIds) { + cancelledPromptIds.add(promptId); + } + for (const requestId of [...pendingClientRequests.keys()]) { + settleClientRequest(requestId, undefined); + } + await session.abort(); + sendResponse(id, {}); + }; + + /** + * Session modes are the Step permission tiers, which are exactly what ACP + * modes describe: a named policy that changes tool availability and how much + * the agent may do without asking. + */ + const buildSessionModeState = (): SessionModeState => ({ + currentModeId: currentPermissionPresetId() ?? STEP_PERMISSION_PRESETS[0]!.id, + availableModes: STEP_PERMISSION_PRESETS.map((preset) => ({ + id: preset.id, + name: preset.label, + description: preset.description, + })), + }); + + /** Read the permission tier back out of the label the extension published. */ + const currentPermissionPresetId = (): string | undefined => { + const text = publishedPermissionLabel; + if (text === undefined || !text.startsWith(PERMISSION_STATUS_PREFIX)) return undefined; + const label = text.slice(PERMISSION_STATUS_PREFIX.length).replace(/\s*\(auto-resume\)$/, ""); + return STEP_PERMISSION_PRESETS.find((preset) => preset.label === label)?.id; + }; + + /** + * Configuration options offered to the client. + * + * Only selectors the session can actually change are listed: the model, and + * the reasoning level when the active model has one. An empty list means the + * client shows no selector rather than one that does nothing. + */ + const MODEL_CONFIG_OPTION_ID = "model"; + const THINKING_CONFIG_OPTION_ID = "thinking"; + + const buildConfigOptions = (): SessionConfigOption[] => { + const options: SessionConfigOption[] = []; + const models = session.modelRuntime.getAvailableSnapshot(); + if (models.length > 0) { + const current = session.model; + options.push({ + id: MODEL_CONFIG_OPTION_ID, + name: "Model", + description: "Model used for the next request", + category: "model", + type: "select", + currentValue: current ? modelOptionValue(current) : "", + options: models.map((model) => ({ + value: modelOptionValue(model), + name: model.name || model.id, + })), + }); + } + if (session.supportsThinking()) { + const levels = session.getAvailableThinkingLevels(); + options.push({ + id: THINKING_CONFIG_OPTION_ID, + name: "Reasoning effort", + category: "thought_level", + type: "select", + currentValue: session.thinkingLevel, + options: levels.map((level) => ({ value: level, name: level })), + }); + } + return options; + }; + + const modelOptionValue = (model: Model): string => `${model.provider}/${model.id}`; + + const notifyCurrentMode = (): void => { + if (!initialized) return; + sendNotification("session/update", { + sessionId: session.sessionId, + update: { sessionUpdate: "current_mode_update", currentModeId: buildSessionModeState().currentModeId }, + }); + }; + + const notifyConfigOptions = (): void => { + if (!initialized) return; + sendNotification("session/update", { + sessionId: session.sessionId, + update: { sessionUpdate: "config_option_update", configOptions: buildConfigOptions() }, + }); + }; + + /** + * Change the permission tier by running the same extension command the TUI runs. + * + * The policy lives in the step extension, so this mode asks it to change + * rather than reimplementing the tiers. That also keeps the full-access risk + * confirmation, which the extension routes back through the ACP permission + * surface like any other approval. + */ + const applyPermissionPreset = async (presetId: string): Promise => { + const command = session.extensionRunner.getCommand("permissions"); + if (!command) return false; + try { + await command.handler(presetId, session.extensionRunner.createCommandContext()); + } catch (error: unknown) { + const reason = error instanceof Error ? error.message : String(error); + process.stderr.write(`[acp] permission command failed: ${reason} +`); + return false; + } + return true; + }; + + /** + * Refuse a session before it starts when nothing can authenticate it. + * + * A session with no credential fails on the first request with a provider + * error the client cannot act on. ACP has a dedicated code for this, and a + * client that receives it runs the `authenticate` flow instead. + */ + const missingAuthError = (): string | undefined => { + // Only the Step provider is gated, and only on the two credential sources + // this product owns. Gating on the runtime provider snapshot would reject + // sessions before the first availability refresh has run, which is a startup + // race rather than a missing credential. + if (session.model?.provider !== STEP_PROVIDER_ID) return undefined; + if (process.env.STEP_API_KEY?.trim()) return undefined; + if (readStepLoginCredential(getStepAuthPath()) !== undefined) return undefined; + return "No Step credentials found. Set STEP_API_KEY or call authenticate."; + }; + + const handleSetSessionMode = async (id: JsonRpcId, params: SetSessionModeParams): Promise => { + if (params.sessionId !== session.sessionId) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown sessionId: ${params.sessionId}`); + return; + } + const preset = getStepPermissionPreset(params.modeId); + if (!preset) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown mode: ${params.modeId}`); + return; + } + if (!(await applyPermissionPreset(preset.id))) { + sendError(id, ACP_JSON_RPC_ERRORS.internal, `Could not switch to mode: ${params.modeId}`); + return; + } + const state = buildSessionModeState(); + notifyCurrentMode(); + sendResponse(id, state); + }; + + const handleSetSessionConfigOption = async (id: JsonRpcId, params: SetSessionConfigOptionParams): Promise => { + if (params.sessionId !== session.sessionId) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown sessionId: ${params.sessionId}`); + return; + } + if (params.optionId === MODEL_CONFIG_OPTION_ID) { + if (typeof params.value !== "string") { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, "model value must be a string"); + return; + } + const model = session.modelRuntime + .getAvailableSnapshot() + .find((candidate) => modelOptionValue(candidate) === params.value); + if (!model) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown model: ${params.value}`); + return; + } + try { + await session.setModel(model); + } catch (error: unknown) { + sendError( + id, + ACP_JSON_RPC_ERRORS.internal, + `Could not switch model: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + } else if (params.optionId === THINKING_CONFIG_OPTION_ID) { + if (typeof params.value !== "string") { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, "thinking value must be a string"); + return; + } + const level = params.value as Parameters[0]; + if (!session.getAvailableThinkingLevels().includes(level)) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown reasoning level: ${params.value}`); + return; + } + session.setThinkingLevel(level); + } else { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown config option: ${params.optionId}`); + return; + } + const configOptions = buildConfigOptions(); + notifyConfigOptions(); + sendResponse(id, configOptions); + }; + + /** + * ACP `session/set_model`. + * + * An alias, not a second selector: it is the model half of + * `session/set_config_option`, carried over from the older unstable surface. + * The method is outside the v1 schema, so it is accepted because clients that + * talk to agents which implement it will send it, and the response mirrors + * `session/set_config_option` because there is no spec shape to follow. + */ + const handleSetSessionModel = async (id: JsonRpcId, params: SetSessionModelParams): Promise => { + if (params.sessionId !== session.sessionId) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown sessionId: ${params.sessionId}`); + return; + } + if (typeof params.modelId !== "string") { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, "modelId must be a string"); + return; + } + const model = session.modelRuntime + .getAvailableSnapshot() + .find((candidate) => modelOptionValue(candidate) === params.modelId); + if (!model) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown model: ${params.modelId}`); + return; + } + try { + await session.setModel(model); + } catch (error: unknown) { + sendError( + id, + ACP_JSON_RPC_ERRORS.internal, + `Could not switch model: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + const configOptions = buildConfigOptions(); + notifyConfigOptions(); + sendResponse(id, configOptions); + }; + + /** + * ACP `session/fork`. + * + * Also outside the v1 schema. The engine already has the primitive, so this + * is a thin mapping: resolve the source session, copy its history into a new + * session file in the same project, and make it active. The response mirrors + * `session/new` for the same reason `session/set_model` mirrors + * `session/set_config_option`: no spec shape exists to follow. + * + * The request's own `mcpServers` is applied to the forked session, matching + * `session/new`. `cwd` and `additionalDirectories` are validated and then + * ignored, which is what the protocol's own extension agents do, because a + * fork inherits the source session's project rather than relocating it. + */ + const handleForkSession = async (id: JsonRpcId, params: ForkSessionParams): Promise => { + let info = (await SessionManager.list(params.cwd)).find((candidate) => candidate.id === params.sessionId); + if (!info) { + info = (await SessionManager.listAll()).find((candidate) => candidate.id === params.sessionId); + } + if (!info) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Session not found: ${params.sessionId}`); + return; + } + if (info.cwd && resolvePath(info.cwd) !== resolvePath(params.cwd)) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Session cwd does not match: ${info.cwd}`); + return; + } + const missingAuth = missingAuthError(); + if (missingAuth !== undefined) { + sendError(id, ACP_JSON_RPC_ERRORS.authRequired, missingAuth); + return; + } + const mcp = applyClientMcpServers(params.mcpServers ?? []); + if (mcp.rejected.length > 0) { + process.stderr.write(`[acp] ignoring MCP servers with unsupported transports: ${mcp.rejected.join(", ")} +`); + } + let forked: SessionManager; + try { + forked = SessionManager.forkFrom(info.path, params.cwd); + } catch (error: unknown) { + sendError( + id, + ACP_JSON_RPC_ERRORS.internal, + `Could not fork session: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + const forkedPath = forked.getSessionFile(); + if (!forkedPath) { + sendError(id, ACP_JSON_RPC_ERRORS.internal, "Forked session was not persisted"); + return; + } + await releaseSessionScopedClientState(); + await runtimeHost.switchSession(forkedPath, { cwdOverride: params.cwd }); + sendResponse(id, { + sessionId: forked.getSessionId(), + modes: buildSessionModeState(), + configOptions: buildConfigOptions(), + }); + }; + + /** + * Authentication methods this agent can offer. + * + * The terminal method is only advertised when the client said it can run the + * agent in an interactive terminal, because that is the only way Step's + * browser login can happen. The API-key method is always available: it + * collects a key through elicitation and stores it where the CLI stores one. + */ + const buildAuthMethods = (): AuthMethod[] => { + const methods: AuthMethod[] = []; + if (clientSupportsTerminalAuth) { + methods.push({ + id: STEP_TERMINAL_AUTH_METHOD_ID, + name: "Log in with a Step account", + description: "Opens a terminal running `step login`. Finish the browser flow there.", + type: "terminal", + args: ["login"], + }); + } + methods.push({ + id: STEP_API_KEY_AUTH_METHOD_ID, + name: "Step API key", + description: "Paste an API key from the Step platform.", + }); + return methods; + }; + + /** + * `authenticate` for the API-key method. + * + * The key is written to the same credential store `step login` uses, so a + * later process finds it without asking again. The account profile is asked + * for in the same form because it selects the endpoint, and guessing it would + * turn a typo into an authentication failure the user cannot explain. + */ + const handleAuthenticate = async (id: JsonRpcId, methodId: string): Promise => { + const method = buildAuthMethods().find((candidate) => candidate.id === methodId); + if (!method) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown auth method: ${methodId}`); + return; + } + if (method.type === "terminal") { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Method ${methodId} is run by the client, not by the agent`); + return; + } + const profiles = resolveStepLoginProfiles().filter((profile) => profile.credentialSource === "apiKey"); + if (profiles.length === 0) { + sendError(id, ACP_JSON_RPC_ERRORS.unavailable, "No API-key account profiles are available"); + return; + } + const content = await elicitForm({ + prompt: [ + "Choose the Step account and paste its API key.", + ...profiles.map((profile) => `- ${profile.id}: ${profile.title}`), + ].join("\n"), + properties: { + profile: { + type: "string", + title: "Account", + enum: profiles.map((profile) => profile.id), + }, + apiKey: { + type: "string", + title: "API key", + }, + }, + required: ["profile", "apiKey"], + }); + const profile = content?.profile; + const apiKey = content?.apiKey; + if (typeof profile !== "string" || typeof apiKey !== "string" || apiKey.trim().length === 0) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, "Authentication was not completed"); + return; + } + if (!profiles.some((candidate) => candidate.id === profile)) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, `Unknown account profile: ${profile}`); + return; + } + try { + await writeStepLoginCredential({ + authPath: getStepAuthPath(), + profile: profile as Parameters[0]["profile"], + apiKey, + }); + } catch (error: unknown) { + sendError( + id, + ACP_JSON_RPC_ERRORS.internal, + `Could not store the credential: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + // The stored credential is picked up on the next request. Refreshing here + // also repopulates the model list the config option selector shows. + void session.modelRuntime.getAvailable().catch(() => undefined); + notifyConfigOptions(); + sendResponse(id, {}); + }; + + /** + * `logout` removes the stored Step credential and the account session. + * + * Sessions already running keep their in-memory credential until they end: + * the protocol makes no promise about them, and pulling it mid-turn would + * fail a turn that is already streaming. + */ + const handleLogout = async (id: JsonRpcId): Promise => { + try { + await clearStepLoginState(); + await logoutStepCredentials(); + } catch (error: unknown) { + sendError( + id, + ACP_JSON_RPC_ERRORS.internal, + `Could not log out: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + void session.modelRuntime.getAvailable().catch(() => undefined); + notifyConfigOptions(); + sendResponse(id, {}); + }; + + const handleRequest = async (request: JsonRpcRequest): Promise => { + const id = request.id; + const isRequest = id !== undefined; + const params = request.params; + + if (request.method !== "initialize" && !initialized) { + if (isRequest) { + sendError(id, ACP_JSON_RPC_ERRORS.notInitialized, "Client must call initialize first"); + } + return; + } + + switch (request.method) { + case "initialize": { + const validation = validateInitializeParams(params); + if (!validation.ok) { + if (isRequest) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + } + return; + } + initialized = true; + clientSupportsFormElicitation = validation.value.clientCapabilities?.elicitation?.form != null; + clientSupportsTerminalAuth = validation.value.clientCapabilities?.auth?.terminal === true; + clientSupportsFsRead = validation.value.clientCapabilities?.fs?.readTextFile === true; + clientSupportsFsWrite = validation.value.clientCapabilities?.fs?.writeTextFile === true; + clientSupportsTerminal = validation.value.clientCapabilities?.terminal === true; + installClientFileSystemBridge(); + installClientTerminalHost(); + if (isRequest) { + sendResponse(id, { + agentCapabilities: { + loadSession: true, + promptCapabilities: { + image: true, + audio: false, + // `resource` blocks are accepted and inlined into the prompt, + // which is what the capability promises. + embeddedContext: true, + }, + mcpCapabilities: { + // stdio is mandatory in ACP and is what `session/new` accepts by + // default. Both remote transports the protocol defines are advertised: + // the engine connects Streamable HTTP, and SSE is wired through its own + // transport even though the protocol marks it deprecated, because + // clients still send it. + http: true, + sse: true, + }, + // ACP gates every optional session method behind a capability, so a + // client that follows the spec will not call an unadvertised one. + // An empty object means "supported"; the key names carry no value. + sessionCapabilities: { + list: {}, + close: {}, + delete: {}, + resume: {}, + }, + auth: { logout: {} }, + }, + agentInfo: { + name: "Step Code", + version: VERSION, + }, + authMethods: buildAuthMethods(), + protocolVersion: ACP_PROTOCOL_VERSION, + }); + } + return; + } + + case "session/new": { + if (!isRequest) return; + const validation = validateNewSessionParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + await handleNewSession(id, validation.cwd, validation.mcpServers, validation.additionalDirectories); + return; + } + + case "session/load": { + if (!isRequest) return; + const validation = validateLoadSessionParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + await handleLoadSession(id, { + sessionId: validation.sessionId, + cwd: validation.cwd, + mcpServers: validation.mcpServers, + additionalDirectories: validation.additionalDirectories, + }); + return; + } + + case "session/prompt": { + if (!isRequest) return; + const validation = validatePromptParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + // Do not await the turn here: stdin must stay readable so + // session/cancel and session/request_permission responses arrive. + void handlePrompt(id, validation.value); + return; + } + + case "session/resume": { + if (!isRequest) return; + const validation = validateResumeSessionParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + await handleResumeSession(id, validation.value); + return; + } + + case "session/list": { + if (!isRequest) return; + const validation = validateListSessionsParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + await handleListSessions(id, validation.value); + return; + } + + case "session/delete": { + if (!isRequest) return; + const validation = validateDeleteSessionParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + await handleDeleteSession(id, validation.value.sessionId); + return; + } + + case "session/close": { + if (!isRequest) return; + const validation = validateCloseSessionParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + await handleCloseSession(id, validation.value); + return; + } + + case "authenticate": { + if (!isRequest) return; + const validation = validateAuthenticateParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + await handleAuthenticate(id, validation.value.methodId); + return; + } + + case "logout": { + if (!isRequest) return; + await handleLogout(id); + return; + } + + case "session/set_mode": { + if (!isRequest) return; + const validation = validateSetSessionModeParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + await handleSetSessionMode(id, validation.value); + return; + } + + case "session/set_config_option": { + if (!isRequest) return; + const validation = validateSetSessionConfigOptionParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + await handleSetSessionConfigOption(id, validation.value); + return; + } + + case "session/set_model": { + if (!isRequest) return; + const validation = validateSetSessionModelParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + await handleSetSessionModel(id, validation.value); + return; + } + + case "session/fork": { + if (!isRequest) return; + const validation = validateForkSessionParams(params); + if (!validation.ok) { + sendError(id, ACP_JSON_RPC_ERRORS.invalidParams, validation.message); + return; + } + await handleForkSession(id, validation.value); + return; + } + + case "session/cancel": { + if (isRequest) return; + for (const promptId of activePromptIds) { + cancelledPromptIds.add(promptId); + } + for (const requestId of [...pendingClientRequests.keys()]) { + settleClientRequest(requestId, undefined); + } + await session.abort(); + return; + } + + default: { + if (isRequest) { + sendError(id, ACP_JSON_RPC_ERRORS.methodNotFound, `Unsupported method: ${request.method}`); + } + } + } + }; + + /** + * Route a client response to whichever request it answers. + * + * The parser lives with the caller, so an unparseable or error response is + * handed over as `undefined` and the waiting call decides what that means: + * a permission treats it as cancelled, an elicitation as a dismissed form. + */ + const handleClientResponse = (response: JsonRpcResponseObject) => { + if (response.error === undefined && response.result === undefined) return; + const id = response.id; + if (typeof id !== "string" && typeof id !== "number") return; + if (response.error !== undefined) { + settleClientRequest(String(id), undefined); + return; + } + settleClientRequest(String(id), response.result); + }; + + const handleCancelNotification = (params: unknown) => { + if (typeof params !== "object" || params === null) return; + const requestId = (params as { requestId?: unknown }).requestId; + if (typeof requestId === "string" || typeof requestId === "number") { + if (activePromptIds.has(requestId)) { + cancelledPromptIds.add(requestId); + void session.abort(); + } + settleClientRequest(String(requestId), undefined); + } + }; + + const handleInputLine = async (line: string) => { + const parsed = parseJsonRpcLine(line); + if (parsed.error) { + sendError(null, parsed.error.code, parsed.error.message); + await waitForRawStdoutBackpressure(); + return; + } + if (parsed.response) { + handleClientResponse(parsed.response); + return; + } + if (!parsed.request) return; + const request = parsed.request; + + if (request.method === "$/cancel_request") { + handleCancelNotification(request.params); + return; + } + + if (request.id === undefined) { + if (request.method === "session/cancel") { + await handleRequest(request); + } + return; + } + + await handleRequest(request); + await waitForRawStdoutBackpressure(); + }; + + async function shutdown(exitCode = 0, signal?: NodeJS.Signals): Promise { + if (shuttingDown) { + process.exit(exitCode); + } + shuttingDown = true; + killTrackedDetachedChildren(); + await releaseSessionScopedClientState(); + setClientFileSystemBridge(undefined); + for (const cleanup of signalCleanupHandlers) { + cleanup(); + } + for (const requestId of [...pendingClientRequests.keys()]) { + settleClientRequest(requestId, undefined); + } + unsubscribe?.(); + unsubscribeBackpressure?.(); + await runtimeHost.dispose(); + detachInput(); + process.stdin.pause(); + if (signal !== "SIGTERM") { + await flushRawStdout(); + } + process.exit(exitCode); + } + + const onInputEnd = () => { + void shutdown(); + }; + process.stdin.on("end", onInputEnd); + + detachInput = (() => { + const detachJsonl = attachJsonlLineReader(process.stdin, (line) => { + void handleInputLine(line); + }); + return () => { + detachJsonl(); + process.stdin.off("end", onInputEnd); + }; + })(); + + // Keep process alive forever + return new Promise(() => {}); +} diff --git a/packages/coding-agent/src/modes/acp/acp-types.ts b/packages/coding-agent/src/modes/acp/acp-types.ts new file mode 100644 index 00000000..d2868319 --- /dev/null +++ b/packages/coding-agent/src/modes/acp/acp-types.ts @@ -0,0 +1,408 @@ +/** Minimal ACP v1 wire types used by the native stdio mode. */ + +export type JsonRpcId = string | number; + +export interface JsonRpcRequest { + jsonrpc?: "2.0"; + id?: JsonRpcId; + method: string; + params?: unknown; +} + +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: JsonRpcId | null; + result?: unknown; + error?: JsonRpcError; +} + +export interface JsonRpcResponseObject { + jsonrpc?: "2.0"; + id: JsonRpcId | null; + result?: unknown; + error?: JsonRpcError; +} + +export interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: unknown; +} + +export interface TextContentBlock { + type: "text"; + text: string; +} + +export interface ImageContentBlock { + type: "image"; + data: string; + mimeType: string; +} + +export interface ResourceLinkContentBlock { + type: "resource_link"; + name: string; + uri: string; +} + +/** + * A resource embedded whole in a prompt. `text` carries a text resource and + * `blob` a base64 binary one; ACP calls these "embedded context". + */ +export interface ResourceContentBlock { + type: "resource"; + resource: { uri: string; mimeType?: string; text?: string; blob?: string }; +} + +export type ContentBlock = TextContentBlock | ImageContentBlock | ResourceLinkContentBlock | ResourceContentBlock; + +export interface InitializeParams { + protocolVersion: number; + clientCapabilities?: { + fs?: { readTextFile?: boolean; writeTextFile?: boolean }; + terminal?: boolean; + auth?: { terminal?: boolean }; + /** Which elicitation modes the client can render. */ + elicitation?: { form?: Record | null; url?: Record | null }; + }; + clientInfo?: { name: string; version: string; title?: string }; +} + +/** ACP `authenticate` selects one of the `authMethods` returned by initialize. */ +export interface AuthenticateParams { + methodId: string; +} + +/** + * An authentication method advertised in the `initialize` response. + * + * `terminal` methods are run by the client itself as a separate interactive + * process; the spec forbids passing them to `authenticate`. Agent methods are + * handled here, inside `authenticate`. + */ +export type AuthMethod = + | { id: string; name: string; description?: string; type?: "agent" } + | { + id: string; + name: string; + description?: string; + type: "terminal"; + args?: string[]; + env?: Record; + }; + +/** + * ACP `elicitation/create` asks the user something with a fixed schema. + * + * Only the `form` mode is implemented: the client renders a form from + * `requestedSchema`. URL-based elicitation needs a client that can open a + * browser and report completion, which the baseline does not assume. + */ +export interface ElicitationCreateParams { + sessionId: string; + mode: "form"; + prompt: string; + requestedSchema: { + type: "object"; + properties: Record; + required?: string[]; + title?: string; + description?: string; + }; + _meta?: Record; +} + +export type ElicitationAction = "accept" | "decline" | "cancel"; + +export interface ElicitationResult { + action: ElicitationAction; + /** Present only for `accept`. Keys come from `requestedSchema.properties`. */ + content?: Record; + _meta?: Record; +} + +export interface NewSessionParams { + cwd: string; + mcpServers: unknown[]; + additionalDirectories?: string[]; +} + +export interface LoadSessionParams { + sessionId: string; + cwd: string; + mcpServers: unknown[]; + additionalDirectories?: string[]; +} + +/** + * Resume deliberately carries no replay switch. + * + * The protocol splits the two: `session/load` MUST replay the conversation and + * `session/resume` MUST NOT, so there is nothing here for a client to ask for. + */ +export interface ResumeSessionParams { + sessionId: string; + cwd: string; + mcpServers?: unknown[]; + /** Normalized by the wire validator, which always fills it (empty when absent). */ + additionalDirectories: string[]; +} + +export interface ListSessionsParams { + cwd?: string; + cursor?: string; +} + +export interface ListSessionsResult { + sessions: Array<{ + sessionId: string; + cwd: string; + title?: string; + updatedAt?: string; + _meta?: Record; + }>; + nextCursor?: string; +} + +export interface DeleteSessionParams { + sessionId: string; +} + +export interface CloseSessionParams { + sessionId: string; +} + +export interface SetSessionModeParams { + sessionId: string; + modeId: string; +} + +/** A mode the agent can operate in, and the one currently active. */ +export interface SessionMode { + id: string; + name: string; + description?: string; +} + +export interface SessionModeState { + currentModeId: string; + availableModes: SessionMode[]; +} + +/** + * A single-value selector configuration option. Booleans are part of the ACP + * union; Step exposes only selectors today, so the union is modelled as one + * optional shape rather than a variant this mode cannot produce. + */ +export interface SessionConfigOption { + id: string; + name: string; + description?: string; + category?: string; + type: "select"; + currentValue: string; + options: Array<{ value: string; name: string; description?: string }>; +} + +export interface SetSessionConfigOptionParams { + sessionId: string; + optionId: string; + /** Absent means the payload is a `SessionConfigValueId` string. */ + type?: "boolean"; + value: string | boolean; +} + +export interface PromptParams { + sessionId: string; + prompt: ContentBlock[]; +} + +/** + * ACP `session/fork`. + * + * Not part of the v1 schema: the method set is fixed at the twelve listed in + * this file, and fork is an extension other agents carry. It is implemented + * because clients that talk to those agents send it, and the response mirrors + * `session/new` because there is no spec shape to follow. + */ +export interface ForkSessionParams { + sessionId: string; + cwd: string; + mcpServers?: unknown[]; + additionalDirectories?: string[]; +} + +/** + * ACP `session/set_model`, also outside the v1 schema. It is the model half of + * `session/set_config_option`, carried over from the older unstable surface, so + * it is accepted as an alias rather than as a second selector. + */ +export interface SetSessionModelParams { + sessionId: string; + modelId: string; +} + +/** Client-side file read. The spec names the result field `content`. */ +export interface ReadTextFileParams { + sessionId: string; + path: string; + line?: number; + limit?: number; +} + +export interface ReadTextFileResult { + content: string; +} + +/** Client-side file write. The client creates the file when it is missing. */ +export interface WriteTextFileParams { + sessionId: string; + path: string; + content: string; +} + +export interface TerminalEnvVariable { + name: string; + value: string; +} + +/** + * `terminal/create` takes a program plus arguments, not a shell string. + * + * The agent's own command pipeline is a shell string, which is why the terminal + * surface is a forwarding layer rather than the bash tool's execution path: see + * the note on `ClientTerminalRegistry`. + */ +export interface TerminalCreateParams { + sessionId: string; + command: string; + args?: string[]; + env?: TerminalEnvVariable[]; + cwd?: string; + outputByteLimit?: number; +} + +export interface TerminalCreateResult { + terminalId: string; +} + +export interface TerminalExitStatus { + exitCode?: number | null; + signal?: string | null; +} + +export interface TerminalOutputParams { + sessionId: string; + terminalId: string; +} + +export interface TerminalOutputResult { + output: string; + truncated: boolean; + exitStatus?: TerminalExitStatus; +} + +/** + * `terminal/wait_for_exit` resolves once the command exits. Both fields are + * optional because a process killed by a signal has no exit code. + */ +export interface TerminalWaitForExitParams { + sessionId: string; + terminalId: string; +} + +export interface TerminalWaitForExitResult { + exitCode?: number | null; + signal?: string | null; +} + +export interface TerminalReleaseParams { + sessionId: string; + terminalId: string; +} + +export interface TerminalKillParams { + sessionId: string; + terminalId: string; +} + +export interface SessionUpdateTextChunk { + sessionUpdate: "user_message_chunk" | "agent_message_chunk" | "agent_thought_chunk"; + content: ContentBlock; +} + +/** One entry in a `tool_call` or `tool_call_update` content array. */ +export interface ToolCallContent { + type: "content"; + content: ContentBlock; +} + +export interface SessionUpdateToolCall { + sessionUpdate: "tool_call"; + toolCallId: string; + title: string; + name?: string | null; + kind?: string; + status?: "pending" | "in_progress" | "completed" | "failed"; + content?: ToolCallContent[]; + rawInput?: unknown; +} + +export interface SessionUpdateToolCallUpdate { + sessionUpdate: "tool_call_update"; + toolCallId: string; + title?: string | null; + status?: "pending" | "in_progress" | "completed" | "failed"; + content?: ToolCallContent[] | null; + rawInput?: unknown; + rawOutput?: unknown; +} + +export type SessionUpdate = SessionUpdateTextChunk | SessionUpdateToolCall | SessionUpdateToolCallUpdate; + +export interface SessionNotificationParams { + sessionId: string; + update: SessionUpdate; +} + +export interface RequestPermissionParams { + sessionId: string; + toolCall: { + toolCallId: string; + title: string; + status?: "pending" | "in_progress" | "completed" | "failed"; + content?: unknown[]; + }; + options: Array<{ optionId: string; name: string; kind: string }>; +} + +export type RequestPermissionResult = + | { outcome: { outcome: "cancelled" } } + | { outcome: { outcome: "selected"; optionId: string } }; + +export const ACP_PROTOCOL_VERSION = 1; + +/** + * ACP reuses the JSON-RPC reserved range for its own codes. `authRequired` and + * `resourceNotFound` come from the protocol's `ErrorCode` union; a client that + * receives them is expected to run the matching recovery flow rather than retry. + */ +export const ACP_JSON_RPC_ERRORS = { + parse: -32700, + invalidRequest: -32600, + methodNotFound: -32601, + invalidParams: -32602, + internal: -32603, + authRequired: -32000, + resourceNotFound: -32001, + notInitialized: -32002, + unavailable: -32003, + requestCancelled: -32800, +} as const; diff --git a/packages/coding-agent/src/modes/acp/acp-wire.ts b/packages/coding-agent/src/modes/acp/acp-wire.ts new file mode 100644 index 00000000..c81fc417 --- /dev/null +++ b/packages/coding-agent/src/modes/acp/acp-wire.ts @@ -0,0 +1,938 @@ +import { isAbsolute } from "node:path"; +import type { AgentMessage } from "@step-harness/agent-core"; +import type { ImageContent } from "@step-harness/providers"; +import type { AgentSessionEvent } from "../../core/agent-session.ts"; +import type { McpServerDeclaration } from "../../step/mcp.ts"; +import type { + AuthenticateParams, + CloseSessionParams, + ContentBlock, + DeleteSessionParams, + ElicitationResult, + ForkSessionParams, + InitializeParams, + JsonRpcError, + JsonRpcId, + JsonRpcRequest, + JsonRpcResponse, + JsonRpcResponseObject, + ListSessionsParams, + ListSessionsResult, + PromptParams, + ReadTextFileParams, + RequestPermissionResult, + ResumeSessionParams, + SessionNotificationParams, + SessionUpdate, + SetSessionConfigOptionParams, + SetSessionModelParams, + SetSessionModeParams, + TerminalCreateParams, + TerminalKillParams, + TerminalOutputParams, + TerminalReleaseParams, + TerminalWaitForExitParams, + ToolCallContent, + WriteTextFileParams, +} from "./acp-types.ts"; +import { ACP_JSON_RPC_ERRORS } from "./acp-types.ts"; + +export function serializeJsonLine(value: unknown): string { + return `${JSON.stringify(value)}\n`; +} + +export function createResponse(id: JsonRpcId | null, result: unknown): JsonRpcResponse { + return { jsonrpc: "2.0", id, result }; +} + +export function createErrorResponse( + id: JsonRpcId | null, + code: number, + message: string, + data?: unknown, +): JsonRpcResponse { + const error: JsonRpcError = { code, message }; + if (data !== undefined) { + error.data = data; + } + return { jsonrpc: "2.0", id, error }; +} + +export function createNotification( + method: string, + params: unknown, +): { jsonrpc: "2.0"; method: string; params: unknown } { + return { jsonrpc: "2.0", method, params }; +} + +export function parseJsonRpcLine(line: string): { + request?: JsonRpcRequest; + response?: JsonRpcResponseObject; + error?: JsonRpcError; +} { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + return { error: { code: ACP_JSON_RPC_ERRORS.parse, message: "Invalid JSON" } }; + } + if (!isRecord(value)) { + return { error: { code: ACP_JSON_RPC_ERRORS.invalidRequest, message: "Not a JSON-RPC message" } }; + } + const id = value.id; + if (id !== undefined && typeof id !== "string" && typeof id !== "number") { + return { error: { code: ACP_JSON_RPC_ERRORS.invalidRequest, message: "Invalid id" } }; + } + if (value.jsonrpc !== undefined && value.jsonrpc !== "2.0") { + return { error: { code: ACP_JSON_RPC_ERRORS.invalidRequest, message: "Unsupported JSON-RPC version" } }; + } + + if (typeof value.method === "string" && value.method.length > 0) { + const request: JsonRpcRequest = { method: value.method, params: value.params }; + if (id !== undefined) request.id = id; + if (value.jsonrpc !== undefined) request.jsonrpc = "2.0"; + return { request }; + } + + if (id !== undefined && ("result" in value || "error" in value)) { + const response: JsonRpcResponseObject = { id }; + if ("result" in value) response.result = value.result; + if ("error" in value) response.error = value.error as JsonRpcError; + if (value.jsonrpc !== undefined) response.jsonrpc = "2.0"; + return { response }; + } + + return { error: { code: ACP_JSON_RPC_ERRORS.invalidRequest, message: "Not a JSON-RPC request or response" } }; +} + +/** + * Ceiling on tool output sent to the client, in characters. + * + * A `read` of a large file or a verbose command produces far more than a + * client can usefully render inline, and ACP puts no limit of its own on + * `tool_call` content. Truncating keeps one tool call from flooding the + * client's transcript; the model still receives the full result, so nothing + * is lost from the conversation. + */ +const TOOL_OUTPUT_CHAR_LIMIT = 4000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function validateInitializeParams( + params: unknown, +): { ok: true; value: InitializeParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Initialize params must be an object" }; + } + if (typeof params.protocolVersion !== "number") { + return { ok: false, message: "protocolVersion must be a number" }; + } + if (params.clientCapabilities !== undefined && !isRecord(params.clientCapabilities)) { + return { ok: false, message: "clientCapabilities must be an object" }; + } + const capabilities = params.clientCapabilities; + if (capabilities?.fs !== undefined && !isRecord(capabilities.fs)) { + return { ok: false, message: "clientCapabilities.fs must be an object" }; + } + if (capabilities?.auth !== undefined && !isRecord(capabilities.auth)) { + return { ok: false, message: "clientCapabilities.auth must be an object" }; + } + if (capabilities?.elicitation !== undefined && !isRecord(capabilities.elicitation)) { + return { ok: false, message: "clientCapabilities.elicitation must be an object" }; + } + return { + ok: true, + value: { + protocolVersion: params.protocolVersion, + ...(capabilities === undefined + ? {} + : { clientCapabilities: capabilities as InitializeParams["clientCapabilities"] }), + ...(isRecord(params.clientInfo) ? { clientInfo: params.clientInfo as InitializeParams["clientInfo"] } : {}), + }, + }; +} + +export function validateNewSessionParams( + params: unknown, +): { ok: true; cwd: string; mcpServers: unknown; additionalDirectories: string[] } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Session params must be an object" }; + } + if (typeof params.cwd !== "string" || !params.cwd) { + return { ok: false, message: "cwd is required" }; + } + if (!isAbsolute(params.cwd)) { + return { ok: false, message: "cwd must be an absolute path" }; + } + if (params.mcpServers !== undefined && !Array.isArray(params.mcpServers)) { + return { ok: false, message: "mcpServers must be an array" }; + } + // Validated and passed through even though the mode does not expand the + // filesystem scope for them. Silently dropping the field would leave a client + // believing it had granted workspace roots the agent cannot reach. + const additionalDirectories = validateAdditionalDirectories(params.additionalDirectories); + if (!additionalDirectories.ok) return additionalDirectories; + return { + ok: true, + cwd: params.cwd, + mcpServers: params.mcpServers, + additionalDirectories: additionalDirectories.value, + }; +} + +/** Normalize `additionalDirectories` to a string array, rejecting any other shape. */ +function validateAdditionalDirectories(value: unknown): { ok: true; value: string[] } | { ok: false; message: string } { + if (value === undefined) return { ok: true, value: [] }; + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !entry)) { + return { ok: false, message: "additionalDirectories must be an array of non-empty strings" }; + } + return { ok: true, value: value as string[] }; +} + +export function validateLoadSessionParams( + params: unknown, +): + | { ok: true; sessionId: string; cwd: string; mcpServers: unknown; additionalDirectories: string[] } + | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Session params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + if (typeof params.cwd !== "string" || !params.cwd) { + return { ok: false, message: "cwd is required" }; + } + if (!isAbsolute(params.cwd)) { + return { ok: false, message: "cwd must be an absolute path" }; + } + if (params.mcpServers !== undefined && !Array.isArray(params.mcpServers)) { + return { ok: false, message: "mcpServers must be an array" }; + } + const additionalDirectories = validateAdditionalDirectories(params.additionalDirectories); + if (!additionalDirectories.ok) return additionalDirectories; + return { + ok: true, + sessionId: params.sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers, + additionalDirectories: additionalDirectories.value, + }; +} + +export function validatePromptParams( + params: unknown, +): { ok: true; value: PromptParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Prompt params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + if (!Array.isArray(params.prompt)) { + return { ok: false, message: "prompt must be an array" }; + } + const prompt: ContentBlock[] = []; + for (const block of params.prompt) { + if (!isRecord(block)) { + return { ok: false, message: "prompt block must be an object" }; + } + const type = block.type; + if (type === "text" && typeof block.text === "string") { + prompt.push({ type: "text", text: block.text }); + continue; + } + if (type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") { + prompt.push({ type: "image", data: block.data, mimeType: block.mimeType }); + continue; + } + if (type === "resource_link" && typeof block.name === "string" && typeof block.uri === "string") { + prompt.push({ type: "resource_link", name: block.name, uri: block.uri }); + continue; + } + if (type === "resource" && isRecord(block.resource)) { + const { uri, mimeType, text, blob } = block.resource; + if (typeof uri !== "string") { + return { ok: false, message: "resource blocks need a uri string" }; + } + if (typeof text !== "string" && typeof blob !== "string") { + return { ok: false, message: "resource blocks need either text or blob" }; + } + prompt.push({ + type: "resource", + resource: { + uri, + ...(typeof mimeType === "string" ? { mimeType } : {}), + ...(typeof text === "string" ? { text } : {}), + ...(typeof blob === "string" ? { blob } : {}), + }, + }); + continue; + } + return { ok: false, message: "Unsupported prompt content block" }; + } + return { ok: true, value: { sessionId: params.sessionId, prompt } }; +} + +export function normalizePrompt(prompt: ContentBlock[]): { text: string; images: ImageContent[] } { + const textParts: string[] = []; + const images: ImageContent[] = []; + for (const block of prompt) { + if (block.type === "text") { + textParts.push(block.text); + continue; + } + if (block.type === "image") { + images.push({ type: "image", data: block.data, mimeType: block.mimeType }); + continue; + } + if (block.type === "resource_link") { + textParts.push(`[[${block.name}]](${block.uri})`); + continue; + } + // Embedded resources arrive with their contents, so the prompt carries the + // payload instead of a link the model would have to fetch itself. + const { uri, text, blob } = block.resource; + if (typeof text === "string") { + textParts.push(`[[resource ${uri}]] +${text}`); + continue; + } + textParts.push(`[[resource ${uri}]] (base64 payload, ${blob?.length ?? 0} chars)`); + } + return { text: textParts.join("\n"), images }; +} + +export function validateResumeSessionParams( + params: unknown, +): { ok: true; value: ResumeSessionParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Resume params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + if (typeof params.cwd !== "string" || !params.cwd) { + return { ok: false, message: "cwd is required" }; + } + if (!isAbsolute(params.cwd)) { + return { ok: false, message: "cwd must be an absolute path" }; + } + if (params.mcpServers !== undefined && !Array.isArray(params.mcpServers)) { + return { ok: false, message: "mcpServers must be an array" }; + } + const additionalDirectories = validateAdditionalDirectories(params.additionalDirectories); + if (!additionalDirectories.ok) return additionalDirectories; + return { + ok: true, + value: { + sessionId: params.sessionId, + cwd: params.cwd, + ...(params.mcpServers === undefined ? {} : { mcpServers: params.mcpServers }), + additionalDirectories: additionalDirectories.value, + }, + }; +} + +export function validateListSessionsParams( + params: unknown, +): { ok: true; value: ListSessionsParams } | { ok: false; message: string } { + if (params === undefined) { + return { ok: true, value: {} }; + } + if (!isRecord(params)) { + return { ok: false, message: "Session list params must be an object" }; + } + if (params.cwd !== undefined && (typeof params.cwd !== "string" || !isAbsolute(params.cwd))) { + return { ok: false, message: "cwd must be an absolute path" }; + } + if (params.cursor !== undefined && typeof params.cursor !== "string") { + return { ok: false, message: "cursor must be a string" }; + } + return { + ok: true, + value: { + ...(params.cwd === undefined ? {} : { cwd: params.cwd as string }), + ...(params.cursor === undefined ? {} : { cursor: params.cursor as string }), + }, + }; +} + +export function validateCloseSessionParams( + params: unknown, +): { ok: true; value: CloseSessionParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Close params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + return { ok: true, value: { sessionId: params.sessionId } }; +} + +export function validateDeleteSessionParams( + params: unknown, +): { ok: true; value: DeleteSessionParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Delete params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + return { ok: true, value: { sessionId: params.sessionId } }; +} + +export function validateAuthenticateParams( + params: unknown, +): { ok: true; value: AuthenticateParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Authenticate params must be an object" }; + } + if (typeof params.methodId !== "string" || !params.methodId) { + return { ok: false, message: "methodId is required" }; + } + return { ok: true, value: { methodId: params.methodId } }; +} + +/** + * Read a client response to an `elicitation/create` request. + * + * `decline` and `cancel` carry no content, so an absent `content` is only an + * error on `accept`. Anything unrecognized returns undefined, so the caller + * cancels the pending request instead of inventing an answer. + */ +export function parseElicitationResult(value: unknown): ElicitationResult | undefined { + if (!isRecord(value)) return undefined; + const action = value.action; + if (action !== "accept" && action !== "decline" && action !== "cancel") return undefined; + if (action !== "accept") { + return { action }; + } + if (value.content !== undefined && !isRecord(value.content)) return undefined; + return { + action, + ...(value.content === undefined ? {} : { content: value.content as Record }), + }; +} + +export function validateSetSessionModeParams( + params: unknown, +): { ok: true; value: SetSessionModeParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Set mode params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + if (typeof params.modeId !== "string" || !params.modeId) { + return { ok: false, message: "modeId is required" }; + } + return { ok: true, value: { sessionId: params.sessionId, modeId: params.modeId } }; +} + +export function validateSetSessionConfigOptionParams( + params: unknown, +): { ok: true; value: SetSessionConfigOptionParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Set config option params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + if (typeof params.optionId !== "string" || !params.optionId) { + return { ok: false, message: "optionId is required" }; + } + if (params.type !== undefined && params.type !== "boolean") { + return { ok: false, message: "type must be boolean when present" }; + } + const isBoolean = params.type === "boolean"; + if (isBoolean ? typeof params.value !== "boolean" : typeof params.value !== "string") { + return { ok: false, message: isBoolean ? "value must be a boolean" : "value must be a string" }; + } + return { + ok: true, + value: { + sessionId: params.sessionId, + optionId: params.optionId, + ...(isBoolean ? { type: "boolean" as const } : {}), + value: params.value as string | boolean, + }, + }; +} +/** + * Turn engine `SessionInfo` records into ACP `session/list` entries. + * + * The engine already computes a display name and a first message; the ACP + * surface exposes whichever is available so a client can label the row without + * opening the session. `messageCount` travels in `_meta` because ACP has no + * field for it and clients show it when present. + */ +export function toAcpSessionListEntries( + sessions: Array<{ + id: string; + cwd: string; + name?: string; + firstMessage?: string; + modified: Date; + messageCount: number; + }>, +): ListSessionsResult["sessions"] { + return sessions.map((session) => ({ + sessionId: session.id, + cwd: session.cwd, + ...(session.name === undefined ? {} : { title: session.name }), + ...(session.name === undefined && session.firstMessage ? { title: session.firstMessage } : {}), + updatedAt: session.modified.toISOString(), + _meta: { messageCount: session.messageCount }, + })); +} + +export interface AcpMcpServer { + name: string; + type?: string; + command?: string; + args?: string[]; + env?: Array<{ name: string; value: string }>; + url?: string; + headers?: Array<{ name: string; value: string }>; +} + +/** + * Convert ACP `mcpServers` into engine MCP declarations, keyed by server name. + * + * ACP v1 sends a named array discriminated by `type`, where an absent `type` + * means stdio; the engine wants a name-keyed record. All three transports the + * protocol defines are accepted: stdio, `http`, and `sse`. SSE is deprecated but + * still defined and still sent, so it is marked on the declaration rather than + * reported and dropped. Only the unstable `acp` transport is rejected, because + * nothing can act on it. + * + * A rejected name is reported rather than dropped silently: a server the client + * asked for and never received is indistinguishable from one that failed to + * start. + */ +export function convertAcpMcpServers(servers: unknown): { + declarations: Record; + rejected: string[]; +} { + const declarations: Record = {}; + const rejected: string[] = []; + if (!Array.isArray(servers)) return { declarations, rejected }; + for (const entry of servers) { + if (!isRecord(entry)) { + rejected.push(""); + continue; + } + const server = entry as unknown as AcpMcpServer; + const name = typeof server.name === "string" ? server.name.trim() : ""; + if (!name) { + rejected.push(""); + continue; + } + if (server.type !== undefined && server.type !== "http" && server.type !== "sse") { + rejected.push(name); + continue; + } + if (server.type === "http" || server.type === "sse") { + if (typeof server.url !== "string" || !server.url) { + rejected.push(name); + continue; + } + const headers = namedPairsToRecord(server.headers); + declarations[name] = { + url: server.url, + // SSE is selected by the transport the client named, not guessed from + // the URL, which cannot be told apart from a streamable-HTTP one. + ...(server.type === "sse" ? { transport: "sse" as const } : {}), + ...(headers ? { http_headers: headers } : {}), + }; + continue; + } + if (typeof server.command !== "string" || !server.command) { + rejected.push(name); + continue; + } + const env = namedPairsToRecord(server.env); + declarations[name] = { + command: server.command, + ...(Array.isArray(server.args) ? { args: server.args.filter((arg) => typeof arg === "string") } : {}), + ...(env ? { env } : {}), + }; + } + return { declarations, rejected }; +} + +function namedPairsToRecord(pairs: unknown): Record | undefined { + if (!Array.isArray(pairs) || pairs.length === 0) return undefined; + const record: Record = {}; + for (const pair of pairs) { + if (!isRecord(pair)) continue; + if (typeof pair.name !== "string" || typeof pair.value !== "string") continue; + record[pair.name] = pair.value; + } + return Object.keys(record).length === 0 ? undefined : record; +} + +export function parseRequestPermissionResult(value: unknown): RequestPermissionResult | null { + if (!isRecord(value) || !isRecord(value.outcome)) return null; + const outcome = value.outcome; + if (outcome.outcome === "cancelled") { + return { outcome: { outcome: "cancelled" } }; + } + if (outcome.outcome === "selected" && typeof outcome.optionId === "string") { + return { outcome: { outcome: "selected", optionId: outcome.optionId } }; + } + return null; +} + +export function mapAssistantEventToSessionUpdate(event: AgentSessionEvent): SessionUpdate | null { + if (event.type === "message_update") { + const assistantEvent = event.assistantMessageEvent; + if (assistantEvent.type === "text_delta" && assistantEvent.delta.length > 0) { + return { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: assistantEvent.delta }, + }; + } + if (assistantEvent.type === "thinking_delta" && assistantEvent.delta.length > 0) { + return { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: assistantEvent.delta }, + }; + } + return null; + } + if (event.type === "tool_execution_start") { + return { + sessionUpdate: "tool_call", + toolCallId: event.toolCallId, + title: buildToolTitle(event.toolName, event.args), + name: event.toolName, + kind: mapToolKind(event.toolName), + status: "pending", + content: [], + rawInput: sanitizeToolInput(event.args), + }; + } + if (event.type === "tool_execution_update") { + // The protocol asks for an in_progress status once the tool is running, + // and this is the only event between start and end. Without it a client + // sees a call appear as pending and then jump straight to its outcome. + return { + sessionUpdate: "tool_call_update", + toolCallId: event.toolCallId, + status: "in_progress", + ...(event.partialResult == null ? {} : { content: [toolOutputContent(event.partialResult)] }), + }; + } + if (event.type === "tool_execution_end") { + const update: SessionUpdate = { + sessionUpdate: "tool_call_update", + toolCallId: event.toolCallId, + status: event.isError ? "failed" : "completed", + }; + if (event.isError) { + update.rawOutput = { error: stringifyToolOutput(event.result) }; + } else if (event.result !== undefined && event.result !== null) { + // A completed call reports what it produced. Sending the outcome only + // on failure leaves the client unable to show what a successful tool + // did, which is the case it most needs to render. + update.content = [toolOutputContent(event.result)]; + } + return update; + } + return null; +} + +export function toSessionNotification(sessionId: string, event: AgentSessionEvent): SessionNotificationParams | null { + const update = mapAssistantEventToSessionUpdate(event); + if (!update) return null; + return { sessionId, update }; +} + +/** + * The error message of the turn that just finished, or undefined if it did not + * fail. + * + * `session.prompt()` resolves even when the model call fails: the failure is + * recorded on the assistant message instead of being thrown. ACP v1 has no + * error stop reason (its `StopReason` covers end_turn, max_tokens, + * max_turn_requests, refusal and cancelled), so the JSON-RPC error channel is + * the only place a client can learn that its turn produced nothing. Reporting + * `end_turn` instead would tell the client the agent answered when it did not. + * + * Only the last assistant message is inspected: an older failed turn must not + * be replayed onto a later successful one. + */ +export function extractTurnError(messages: readonly AgentMessage[]): string | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message.role !== "assistant") continue; + if (message.stopReason !== "error") return undefined; + return message.errorMessage ?? "The turn ended with an error."; + } + return undefined; +} + +function stringifyToolOutput(value: unknown): unknown { + if (value === undefined || value === null) return null; + if (typeof value === "string") return value; + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +function buildToolTitle(name: string, input: unknown): string { + if (!name) return "tool"; + if (!isRecord(input)) return name; + const target = input.path ?? input.file ?? input.filePath ?? input.command ?? input.query ?? input.url; + if (typeof target === "string" && target.length > 0 && target.length <= 120) { + return `${name} ${target}`; + } + return name; +} + +/** + * Wrap a tool result as one ACP content block, truncated to the ceiling. + * + * ACP v1 content blocks are text, image or audio, and a tool result here is + * whatever the tool returned, so it is rendered as text rather than typed. A + * result that is already text is passed through unchanged. + */ +function toolOutputContent(result: unknown): ToolCallContent { + const text = stringifyToolOutput(result); + const rendered = typeof text === "string" ? text : String(text); + if (rendered.length <= TOOL_OUTPUT_CHAR_LIMIT) { + return { type: "content", content: { type: "text", text: rendered } }; + } + const note = `[truncated, ${rendered.length} characters total]`; + return { + type: "content", + // The marker names what was cut, so a client does not present a partial + // read as the whole result. The model still receives the full text. + content: { + type: "text", + text: `${rendered.slice(0, TOOL_OUTPUT_CHAR_LIMIT)} +${note}`, + }, + }; +} + +function sanitizeToolInput(input: unknown): unknown { + if (!isRecord(input)) return input; + const sanitized: Record = {}; + for (const [key, value] of Object.entries(input)) { + const lowered = key.toLowerCase(); + if (lowered.includes("token") || lowered.includes("secret") || lowered.includes("password")) { + sanitized[key] = "[redacted]"; + continue; + } + sanitized[key] = value; + } + return sanitized; +} + +function mapToolKind(name: string): string { + switch (name) { + case "bash": + return "execute"; + case "read": + return "read"; + case "write": + return "edit"; + case "edit": + return "edit"; + case "glob": + return "search"; + case "grep": + return "search"; + case "web_search": + return "fetch"; + case "web_fetch": + return "fetch"; + default: + return "other"; + } +} + +/** + * Shared shape for the four terminal methods that take only a terminal id. + * + * `sessionId` is required by the protocol but is not trusted here: the registry + * re-derives the session from the live mode, so a client naming someone else's + * session is rejected there rather than by a string comparison that could drift. + */ +function validateTerminalIdParams( + params: unknown, + label: string, +): { ok: true; value: { sessionId: string; terminalId: string } } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: `${label} params must be an object` }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + if (typeof params.terminalId !== "string" || !params.terminalId) { + return { ok: false, message: "terminalId is required" }; + } + return { ok: true, value: { sessionId: params.sessionId, terminalId: params.terminalId } }; +} + +export function validateReadTextFileParams( + params: unknown, +): { ok: true; value: ReadTextFileParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Read params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + if (typeof params.path !== "string" || !params.path) { + return { ok: false, message: "path is required" }; + } + const line = typeof params.line === "number" && Number.isFinite(params.line) ? params.line : undefined; + const limit = typeof params.limit === "number" && Number.isFinite(params.limit) ? params.limit : undefined; + return { + ok: true, + value: { + sessionId: params.sessionId, + path: params.path, + ...(line === undefined ? {} : { line }), + ...(limit === undefined ? {} : { limit }), + }, + }; +} + +export function validateWriteTextFileParams( + params: unknown, +): { ok: true; value: WriteTextFileParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Write params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + if (typeof params.path !== "string" || !params.path) { + return { ok: false, message: "path is required" }; + } + if (typeof params.content !== "string") { + return { ok: false, message: "content must be a string" }; + } + return { ok: true, value: { sessionId: params.sessionId, path: params.path, content: params.content } }; +} + +export function validateTerminalCreateParams( + params: unknown, +): { ok: true; value: TerminalCreateParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Terminal create params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + if (typeof params.command !== "string" || !params.command) { + return { ok: false, message: "command is required" }; + } + const args = Array.isArray(params.args) + ? params.args.filter((arg): arg is string => typeof arg === "string") + : undefined; + const env = Array.isArray(params.env) + ? params.env + .filter( + (entry): entry is { name: string; value: string } => + isRecord(entry) && typeof entry.name === "string" && typeof entry.value === "string", + ) + .map((entry) => ({ name: entry.name, value: entry.value })) + : undefined; + const cwd = typeof params.cwd === "string" && params.cwd ? params.cwd : undefined; + const outputByteLimit = + typeof params.outputByteLimit === "number" && Number.isFinite(params.outputByteLimit) + ? params.outputByteLimit + : undefined; + return { + ok: true, + value: { + sessionId: params.sessionId, + command: params.command, + ...(args === undefined ? {} : { args }), + ...(env === undefined ? {} : { env }), + ...(cwd === undefined ? {} : { cwd }), + ...(outputByteLimit === undefined ? {} : { outputByteLimit }), + }, + }; +} + +export function validateTerminalOutputParams( + params: unknown, +): { ok: true; value: TerminalOutputParams } | { ok: false; message: string } { + return validateTerminalIdParams(params, "Terminal output"); +} + +export function validateTerminalWaitForExitParams( + params: unknown, +): { ok: true; value: TerminalWaitForExitParams } | { ok: false; message: string } { + return validateTerminalIdParams(params, "Terminal wait"); +} + +export function validateTerminalReleaseParams( + params: unknown, +): { ok: true; value: TerminalReleaseParams } | { ok: false; message: string } { + return validateTerminalIdParams(params, "Terminal release"); +} + +export function validateTerminalKillParams( + params: unknown, +): { ok: true; value: TerminalKillParams } | { ok: false; message: string } { + return validateTerminalIdParams(params, "Terminal kill"); +} + +export function validateForkSessionParams( + params: unknown, +): { ok: true; value: ForkSessionParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Fork params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + if (typeof params.cwd !== "string" || !params.cwd) { + return { ok: false, message: "cwd is required" }; + } + // Absolute like every other session-lifecycle method: the fork is written + // under this path and matched against the source session's stored cwd, so a + // relative one would resolve against whatever directory the agent happens to + // run in and fail with a message that names the wrong place. + if (!isAbsolute(params.cwd)) { + return { ok: false, message: "cwd must be an absolute path" }; + } + return { + ok: true, + value: { + sessionId: params.sessionId, + cwd: params.cwd, + ...(Array.isArray(params.mcpServers) ? { mcpServers: params.mcpServers } : {}), + ...(Array.isArray(params.additionalDirectories) + ? { additionalDirectories: params.additionalDirectories } + : {}), + }, + }; +} + +export function validateSetSessionModelParams( + params: unknown, +): { ok: true; value: SetSessionModelParams } | { ok: false; message: string } { + if (!isRecord(params)) { + return { ok: false, message: "Set model params must be an object" }; + } + if (typeof params.sessionId !== "string" || !params.sessionId) { + return { ok: false, message: "sessionId is required" }; + } + if (typeof params.modelId !== "string" || !params.modelId) { + return { ok: false, message: "modelId is required" }; + } + return { ok: true, value: { sessionId: params.sessionId, modelId: params.modelId } }; +} diff --git a/packages/coding-agent/src/modes/acp/client-terminal.ts b/packages/coding-agent/src/modes/acp/client-terminal.ts new file mode 100644 index 00000000..a51f0b40 --- /dev/null +++ b/packages/coding-agent/src/modes/acp/client-terminal.ts @@ -0,0 +1,138 @@ +import type { + TerminalCreateParams, + TerminalCreateResult, + TerminalEnvVariable, + TerminalKillParams, + TerminalOutputParams, + TerminalOutputResult, + TerminalReleaseParams, + TerminalWaitForExitParams, + TerminalWaitForExitResult, +} from "./acp-types.ts"; + +/** Sends one request to the client and waits for its response. */ +export type ClientRequest = (method: string, params: unknown) => Promise; + +/** Raised for a terminal id this agent never handed out, or handed out elsewhere. */ +export class UnknownTerminalError extends Error { + readonly terminalId: string; + + constructor(terminalId: string) { + super(`Unknown terminalId: ${terminalId}`); + this.name = "UnknownTerminalError"; + this.terminalId = terminalId; + } +} + +export interface ClientTerminalRecord { + terminalId: string; + /** Session the terminal was created in, so a later session cannot touch it. */ + sessionId: string; + command: string; + cwd?: string; + createdAt: number; +} + +/** + * Owns the terminals this agent asked the client to create. + * + * This is a forwarding layer, not an execution engine. `terminal/create` asks + * the client to run a program and the client owns the process from then on, so + * the only state worth keeping locally is which ids were handed out and to which + * session. Everything else is passed through, because a second copy of the + * output or the exit status would be a copy that can disagree with the client's. + * + * The agent's own bash tool deliberately does not run through here. ACP's + * `terminal/create` takes a program plus arguments, while the agent's command + * pipeline is a shell string, so routing it would mean choosing a shell, an + * environment, and a polling cadence on the client's behalf. That is a product + * decision about who owns command execution, and it should not be triggered by a + * client setting `terminal: true`. The file methods are different: the client's + * file is the same file, and its read is a superset, so bridging those costs + * nothing semantically. + */ +export class ClientTerminalRegistry { + private readonly terminals = new Map(); + private readonly request: ClientRequest; + /** Read per call, because the active session changes underneath a live mode. */ + private readonly currentSessionId: () => string; + + constructor(request: ClientRequest, currentSessionId: () => string) { + this.request = request; + this.currentSessionId = currentSessionId; + } + + get size(): number { + return this.terminals.size; + } + + async create(params: Omit): Promise { + const sessionId = this.currentSessionId(); + const result = (await this.request("terminal/create", { ...params, sessionId })) as TerminalCreateResult; + const terminalId = typeof result?.terminalId === "string" ? result.terminalId : ""; + if (!terminalId) throw new Error("Client returned no terminalId for terminal/create"); + const record: ClientTerminalRecord = { + terminalId, + sessionId, + command: params.command, + ...(params.cwd === undefined ? {} : { cwd: params.cwd }), + createdAt: Date.now(), + }; + this.terminals.set(terminalId, record); + return record; + } + + async output(terminalId: string): Promise { + const params = this.paramsFor(terminalId); + return (await this.request("terminal/output", params)) as TerminalOutputResult; + } + + async waitForExit(terminalId: string): Promise { + const params = this.paramsFor(terminalId); + return (await this.request("terminal/wait_for_exit", params)) as TerminalWaitForExitResult; + } + + async kill(terminalId: string): Promise { + const params = this.paramsFor(terminalId); + await this.request("terminal/kill", params); + } + + async release(terminalId: string): Promise { + const params = this.paramsFor(terminalId); + this.terminals.delete(terminalId); + await this.request("terminal/release", params); + } + + /** + * Release every terminal still held, on shutdown. + * + * The spec makes release mandatory once a terminal is no longer needed, and a + * mode that exits without releasing leaks the client's terminals for the life + * of the client process. Failures are swallowed because this runs on the way + * out, where there is nobody left to report them to. + */ + async releaseAll(): Promise { + for (const terminalId of [...this.terminals.keys()]) { + try { + await this.release(terminalId); + } catch { + // Already gone, or the client is gone with it. + } + } + } + + private paramsFor(terminalId: string): TerminalOutputParams | TerminalWaitForExitParams { + const record = this.terminals.get(terminalId); + if (!record) throw new UnknownTerminalError(terminalId); + const sessionId = this.currentSessionId(); + if (record.sessionId !== sessionId) throw new UnknownTerminalError(terminalId); + return { sessionId, terminalId }; + } +} + +/** ACP sends environment variables as name/value pairs; the reverse of a record. */ +export function envRecordToPairs(env: Record): TerminalEnvVariable[] { + return Object.entries(env).map(([name, value]) => ({ name, value })); +} + +export type { TerminalCreateParams, TerminalKillParams, TerminalReleaseParams }; diff --git a/packages/coding-agent/src/modes/index.ts b/packages/coding-agent/src/modes/index.ts index 4fd1145e..09bb8b3e 100644 --- a/packages/coding-agent/src/modes/index.ts +++ b/packages/coding-agent/src/modes/index.ts @@ -6,6 +6,7 @@ * (print/rpc/json) stay in this package. */ +export { runAcpMode } from "./acp/acp-mode.ts"; export type { JsonAgentSessionEvent } from "./json-event.ts"; export { type PrintModeOptions, runPrintMode } from "./print-mode.ts"; export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.ts"; diff --git a/packages/coding-agent/src/step/login-flow.ts b/packages/coding-agent/src/step/login-flow.ts index c84db8ae..81fdea8d 100644 --- a/packages/coding-agent/src/step/login-flow.ts +++ b/packages/coding-agent/src/step/login-flow.ts @@ -188,7 +188,8 @@ export function isStepInteractiveLoginStartup(input: { input.args.fileArgs.length === 0 && input.args.print !== true && input.args.mode !== "json" && - input.args.mode !== "rpc" + input.args.mode !== "rpc" && + input.args.mode !== "acp" ); } diff --git a/packages/coding-agent/src/step/mcp-startup.test.ts b/packages/coding-agent/src/step/mcp-startup.test.ts index fb321ed5..56a45d28 100644 --- a/packages/coding-agent/src/step/mcp-startup.test.ts +++ b/packages/coding-agent/src/step/mcp-startup.test.ts @@ -20,8 +20,10 @@ import { describeMcpStartFailure, discoverStepMcpServers, expandHeaderTemplate, + getHostMcpServerOverlay, getStepMcpStatuses, resolveStepMcpServer, + setHostMcpServerOverlay, } from "./mcp.ts"; import type { StepPageReadiness } from "./steppage-provision.ts"; @@ -70,6 +72,9 @@ beforeEach(() => { builtin.disabledUser = false; preinstall.mockReset().mockResolvedValue({ installed: [], warnings: [] }); readiness.mockReset(); + // The overlay is process state; leaving it set would leak servers into + // every later discovery in this file. + setHostMcpServerOverlay(undefined); }); afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); @@ -695,3 +700,62 @@ test("shutdown suppresses late warnings from manifest preinstallation", async () await shutdown; expect(harness.notify).not.toHaveBeenCalled(); }); + +test("a host overlay server is discovered alongside config servers", async () => { + config.value = { mcp_servers: { fromConfig: { command: "config-server" } } }; + setHostMcpServerOverlay({ fromClient: { command: "client-server", args: ["--flag"] } }); + + const discovered = await discoverStepMcpServers(process.cwd(), false); + + expect(discovered.map((server) => server.name).sort()).toEqual(["fromClient", "fromConfig"]); + expect(discovered.find((server) => server.name === "fromClient")?.declaration).toEqual({ + command: "client-server", + args: ["--flag"], + }); +}); + +test("a host overlay server replaces a same-named config entry", async () => { + config.value = { mcp_servers: { shared: { command: "config-server", args: ["--stale"] } } }; + setHostMcpServerOverlay({ shared: { command: "client-server" } }); + + const discovered = await discoverStepMcpServers(process.cwd(), false); + + // Replaced in place, not appended: two entries named `shared` would publish + // the same tool names twice. + expect(discovered).toHaveLength(1); + expect(discovered[0]?.declaration).toEqual({ command: "client-server" }); +}); + +test("a host overlay server coexists with a differently named plugin server", async () => { + // Plugin servers are namespaced `__`, so a client that asks + // for `docs` never collides with a plugin's `docs` and both stay available. + pluginMocks.dirs = ["/unused-test-plugins/local"]; + pluginMocks.manifests.set("/unused-test-plugins/local", { + id: "local", + mcpServers: { docs: { command: "plugin-server" } }, + }); + setHostMcpServerOverlay({ docs: { url: "https://client.example.com/mcp" } }); + + const discovered = await discoverStepMcpServers(process.cwd(), false); + + expect(discovered.map((server) => server.name).sort()).toEqual(["docs", "local__docs"]); + expect(discovered.find((server) => server.name === "docs")?.declaration).toEqual({ + url: "https://client.example.com/mcp", + }); +}); + +test("clearing the host overlay drops its servers from discovery", async () => { + setHostMcpServerOverlay({ fromClient: { command: "client-server" } }); + expect((await discoverStepMcpServers(process.cwd(), false)).map((server) => server.name)).toEqual(["fromClient"]); + + setHostMcpServerOverlay(undefined); + + expect(await discoverStepMcpServers(process.cwd(), false)).toEqual([]); + expect(getHostMcpServerOverlay()).toBeUndefined(); +}); + +test("an overlay entry without a transport is ignored", async () => { + setHostMcpServerOverlay({ broken: { args: ["--no-command-or-url"] } }); + + expect(await discoverStepMcpServers(process.cwd(), false)).toEqual([]); +}); diff --git a/packages/coding-agent/src/step/mcp.ts b/packages/coding-agent/src/step/mcp.ts index e6680b2d..884f1694 100644 --- a/packages/coding-agent/src/step/mcp.ts +++ b/packages/coding-agent/src/step/mcp.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { setImmediate as yieldToEventLoop } from "node:timers/promises"; import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { type Tool as McpTool, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; @@ -40,7 +41,7 @@ interface ConnectedServer { pluginId?: string; readonly name: string; readonly client: Client; - readonly transport: StdioClientTransport | StreamableHTTPClientTransport; + readonly transport: StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport; tools: McpTool[]; /** Ends when this connection closes or its session shuts down. */ readonly signal: AbortSignal; @@ -49,12 +50,28 @@ interface ConnectedServer { readonly callTimeoutMs: number; } -interface ServerDeclaration { +/** + * One MCP server as the engine can start it: a stdio command or a remote URL, + * plus the per-server tuning knobs. + * + * Exported because protocol hosts (ACP) build these from client-supplied + * server lists, which is the same shape `config.toml` uses. + */ +export interface ServerDeclaration { command?: string; args?: string[]; cwd?: string; env?: Record; url?: string; + /** + * Which remote transport a `url` declaration uses. Absent means Streamable + * HTTP, the modern default and what `config.toml` has always meant. + * + * Only a protocol host sets this. ACP defines an SSE transport for MCP + * servers and clients do send it, so the host marks such a server here rather + * than guessing the transport from the URL, which cannot be done reliably. + */ + transport?: "sse"; bearer_token_env_var?: string; http_headers?: Record; env_http_headers?: Record; @@ -71,6 +88,9 @@ interface ServerDeclaration { }; } +/** Public alias for {@link ServerDeclaration}, named for its config-file origin. */ +export type McpServerDeclaration = ServerDeclaration; + interface DiscoveredServer { pluginId?: string; name: string; @@ -86,6 +106,32 @@ export interface StepMcpStatus { let currentMcpStatuses: StepMcpStatus[] = []; const currentMcpToolOwners = new WeakMap>(); + +/** + * Servers contributed by the current protocol host for this process, keyed by + * the name the client used. + * + * ACP clients pass MCP servers per session (`session/new` and `session/load`), + * which has no config-file equivalent: the same binary can be driven by + * different clients, each with its own server list. Discovery therefore merges + * this overlay on top of config.toml and plugin manifests, with the overlay + * winning on a name collision so a client can override a stale global entry. + * + * The overlay is process-scoped on purpose. A host that supports exactly one + * active session at a time (ACP, RPC) replaces it before switching sessions; + * discovery reads it once per `session_start`. + */ +let hostMcpServerOverlay: Map | undefined; + +export function setHostMcpServerOverlay(servers: Record | undefined): void { + hostMcpServerOverlay = servers === undefined ? undefined : new Map(Object.entries(servers)); +} + +export function getHostMcpServerOverlay(): Record | undefined { + return hostMcpServerOverlay === undefined + ? undefined + : Object.fromEntries([...hostMcpServerOverlay].map(([name, declaration]) => [name, { ...declaration }])); +} export function getStepMcpToolOwner(toolName: string, session: object): string | undefined { return currentMcpToolOwners.get(session)?.get(toolName); } @@ -419,9 +465,33 @@ export async function discoverStepMcpServers(cwd: string, projectTrusted: boolea } } } + mergeHostOverlay(result, seen); return result; } +/** + * Merge the host-contributed overlay into a discovery result, in place. + * + * Runs last so a client-provided server replaces a same-named config or plugin + * entry instead of being skipped by the `seen` guard, which is what makes an + * ACP client able to override a stale global `config.toml` entry. Overlay + * entries are already normalized by the host, so they are trusted as-is apart + * from the transport check. + */ +function mergeHostOverlay(result: DiscoveredServer[], seen: Set): void { + if (hostMcpServerOverlay === undefined || hostMcpServerOverlay.size === 0) return; + for (const [name, declaration] of hostMcpServerOverlay) { + if (typeof declaration.command !== "string" && typeof declaration.url !== "string") continue; + const existing = result.findIndex((server) => server.name === name); + if (existing >= 0) { + result[existing] = { name, declaration }; + continue; + } + seen.add(name); + result.push({ name, declaration }); + } +} + /** * Run a plugin's stdio server from the plugin root. * @@ -582,7 +652,7 @@ export async function connectStepMcpServer( abortSignal?.throwIfAborted(); const timeout = timeoutMs(input.declaration.startup_timeout_sec, MCP_STARTUP_TIMEOUT_SEC); const callTimeoutMs = timeoutMs(input.declaration.tool_timeout_sec, MCP_CALL_TIMEOUT_SEC); - let transport: StdioClientTransport | StreamableHTTPClientTransport; + let transport: StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport; if (input.declaration.command) { const env = resolveStepMcpEnvironment(input.declaration.env); let command = input.declaration.command; @@ -603,14 +673,32 @@ export async function connectStepMcpServer( transport.stderr?.on("data", () => undefined); } else if (input.declaration.url) { const headers = resolveHttpHeaders(input.declaration); - transport = new StreamableHTTPClientTransport(new URL(input.declaration.url), { - requestInit: Object.keys(headers).length > 0 ? { headers } : undefined, - ...(hasStoredMcpOAuthCredential(input.name, input.declaration.url, process.env) - ? { - authProvider: createStoredMcpOAuthProvider(input.name, input.declaration.url, process.env), - } - : {}), - }); + if (input.declaration.transport === "sse") { + // The deprecated HTTP+SSE transport. ACP still defines it and clients + // still send it, so it is honoured rather than reported and dropped. + // OAuth is not wired here: the streamable-HTTP path owns that, and an + // SSE server that needs OAuth fails at startup instead of half-working. + // Headers go on both legs: `eventSourceInit` covers the stream the + // transport opens, `requestInit` covers the POSTs it sends, and a + // server behind an API gateway rejects the second without them. + transport = new SSEClientTransport(new URL(input.declaration.url), { + ...(Object.keys(headers).length > 0 + ? { + eventSourceInit: { fetch: (url, init) => fetch(url, { ...init, headers }) }, + requestInit: { headers }, + } + : {}), + }); + } else { + transport = new StreamableHTTPClientTransport(new URL(input.declaration.url), { + requestInit: Object.keys(headers).length > 0 ? { headers } : undefined, + ...(hasStoredMcpOAuthCredential(input.name, input.declaration.url, process.env) + ? { + authProvider: createStoredMcpOAuthProvider(input.name, input.declaration.url, process.env), + } + : {}), + }); + } } else { throw new Error("MCP server must define command or url"); } diff --git a/packages/coding-agent/src/step/tool-profile.ts b/packages/coding-agent/src/step/tool-profile.ts index 4ec53ecd..d26dbaed 100644 --- a/packages/coding-agent/src/step/tool-profile.ts +++ b/packages/coding-agent/src/step/tool-profile.ts @@ -23,7 +23,13 @@ import type { ToolRenderContext, ToolRenderResultOptions, } from "../core/extensions/types.ts"; -import { ShellToolError } from "../core/tools/bash.ts"; +import { createLocalBashOperations, ShellToolError } from "../core/tools/bash.ts"; +import { + withClientFileSystemEditOptions, + withClientFileSystemReadOptions, + withClientFileSystemWriteOptions, +} from "../core/tools/client-fs.ts"; +import { withClientTerminalOperations } from "../core/tools/client-terminal.ts"; import type { EditToolOptions } from "../core/tools/edit.ts"; import { generateDiffString, generateUnifiedPatch, normalizeToLF } from "../core/tools/edit-diff.ts"; import { withFileMutationQueue } from "../core/tools/file-mutation-queue.ts"; @@ -1548,13 +1554,29 @@ export function createStepToolProfile(cwd: string, options: StepToolProfileOptio }; const nativeFindOptions = withAgentDir(options.find); const nativeGrepOptions = withAgentDir(options.grep); - const nativeBashOptions = withAgentDir(options.bash); + // Commands run in the client's terminal when a protocol host installed one, so + // the local shell has to be resolved explicitly rather than left to the tool's + // own default: it is the fallback the wrapper needs, and it supplies the shell + // the client is asked to run. + const localBashOperations = + options.bash?.operations ?? createLocalBashOperations({ shellPath: options.bash?.shellPath, agentDir }); + const nativeBashOptions = withAgentDir({ + ...options.bash, + operations: withClientTerminalOperations(localBashOperations, () => getShellConfig(options.bash?.shellPath)), + }); const nativeLs = createLsToolDefinition(cwd, options.ls); const nativeFind = createFindToolDefinition(cwd, nativeFindOptions); const nativeGrep = createGrepToolDefinition(cwd, nativeGrepOptions); - const nativeRead = createReadToolDefinition(cwd, options.read); - const nativeWrite = createWriteToolDefinition(cwd, options.write); - const nativeEdit = createEditToolDefinition(cwd, options.edit); + // File tools go through the client filesystem when a protocol host installs a + // bridge, so the three tools and the Step-facing aliases that reuse their + // operations must share one normalized options object. Building it once here + // is what keeps an alias from silently staying on the local filesystem. + const readOptions = withClientFileSystemReadOptions(options.read); + const writeOptions = withClientFileSystemWriteOptions(options.write); + const editOptions = withClientFileSystemEditOptions(options.edit); + const nativeRead = createReadToolDefinition(cwd, readOptions); + const nativeWrite = createWriteToolDefinition(cwd, writeOptions); + const nativeEdit = createEditToolDefinition(cwd, editOptions); const nativeBash = createBashToolDefinition(cwd, nativeBashOptions); const searchWeb = createSearchWebTool(options.searchWeb); @@ -1645,7 +1667,7 @@ export function createStepToolProfile(cwd: string, options: StepToolProfileOptio signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, ctx: ExtensionContext, - ) => executeReadFile(nativeRead, args, cwd, options.read, signal, onUpdate, ctx), + ) => executeReadFile(nativeRead, args, cwd, readOptions, signal, onUpdate, ctx), } as AnyToolDefinition; const writeFileBase = aliasDefinition( @@ -1668,7 +1690,7 @@ export function createStepToolProfile(cwd: string, options: StepToolProfileOptio _onUpdate: AgentToolUpdateCallback | undefined, ctx: ExtensionContext, ) => { - const result = await executeStepWriteFile(args, cwd, options.write, signal, ctx); + const result = await executeStepWriteFile(args, cwd, writeOptions, signal, ctx); const thenRun = readThenRunCommand(args); return thenRun === undefined ? result : withThenRunResult(result, runCommand, thenRun, signal, ctx); }, @@ -1689,7 +1711,7 @@ export function createStepToolProfile(cwd: string, options: StepToolProfileOptio executionMode: nativeEdit.executionMode, renderShell: nativeEdit.renderShell, execute: async (toolCallId, args, signal, onUpdate, ctx) => { - const result = await executeStepEdit(nativeEdit, toolCallId, args, cwd, options.edit, signal, onUpdate, ctx); + const result = await executeStepEdit(nativeEdit, toolCallId, args, cwd, editOptions, signal, onUpdate, ctx); const thenRun = readThenRunCommand(args); return thenRun === undefined ? result : withThenRunResult(result, runCommand, thenRun, signal, ctx); }, diff --git a/packages/coding-agent/test/acp-client-terminal.test.ts b/packages/coding-agent/test/acp-client-terminal.test.ts new file mode 100644 index 00000000..943170c5 --- /dev/null +++ b/packages/coding-agent/test/acp-client-terminal.test.ts @@ -0,0 +1,108 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { ClientTerminalRegistry, envRecordToPairs, UnknownTerminalError } from "../src/modes/acp/client-terminal.ts"; + +type Recorded = { method: string; params: Record }; + +function fakeClient(responder: (method: string, params: Record) => unknown = () => ({})) { + const calls: Recorded[] = []; + const request = vi.fn(async (method: string, params: unknown) => { + const record = { method, params: params as Record }; + calls.push(record); + return responder(method, record.params); + }); + return { calls, request }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test("create forwards the request and records the terminal", async () => { + const client = fakeClient(() => ({ terminalId: "term-7" })); + const registry = new ClientTerminalRegistry(client.request, () => "sess-1"); + + const record = await registry.create({ command: "npm", args: ["test"], cwd: "/repo" }); + + expect(record.terminalId).toBe("term-7"); + expect(record.sessionId).toBe("sess-1"); + expect(client.calls).toEqual([ + { method: "terminal/create", params: { command: "npm", args: ["test"], cwd: "/repo", sessionId: "sess-1" } }, + ]); + expect(registry.size).toBe(1); +}); + +test("a client that returns no terminal id is an error, not a silent no-op", async () => { + const client = fakeClient(() => ({})); + const registry = new ClientTerminalRegistry(client.request, () => "sess-1"); + + await expect(registry.create({ command: "npm" })).rejects.toThrow("no terminalId"); + expect(registry.size).toBe(0); +}); + +test("output, wait, kill and release all forward the terminal id", async () => { + const client = fakeClient(() => ({ terminalId: "term-1" })); + const registry = new ClientTerminalRegistry(client.request, () => "sess-1"); + await registry.create({ command: "npm" }); + + await registry.output("term-1"); + await registry.waitForExit("term-1"); + await registry.kill("term-1"); + client.calls.length = 0; + await registry.release("term-1"); + + expect(client.calls.map((call) => call.method)).toEqual(["terminal/release"]); + expect(client.calls[0]?.params).toEqual({ sessionId: "sess-1", terminalId: "term-1" }); + // Released means forgotten, so a second release is an unknown id. + expect(registry.size).toBe(0); +}); + +test("an unknown terminal id is rejected before any request is sent", async () => { + const client = fakeClient(); + const registry = new ClientTerminalRegistry(client.request, () => "sess-1"); + + await expect(registry.output("nope")).rejects.toBeInstanceOf(UnknownTerminalError); + await expect(registry.kill("nope")).rejects.toBeInstanceOf(UnknownTerminalError); + expect(client.calls).toHaveLength(0); +}); + +test("a terminal cannot be used after the session moved on", async () => { + const client = fakeClient(() => ({ terminalId: "term-1" })); + let sessionId = "sess-1"; + const registry = new ClientTerminalRegistry(client.request, () => sessionId); + await registry.create({ command: "npm" }); + + sessionId = "sess-2"; + client.calls.length = 0; + + await expect(registry.output("term-1")).rejects.toBeInstanceOf(UnknownTerminalError); + expect(client.calls).toHaveLength(0); +}); + +test("releaseAll clears every terminal and swallows failures", async () => { + let failNext = true; + const client = fakeClient((method) => { + if (method === "terminal/create") return { terminalId: `term-${Math.random()}` }; + if (method === "terminal/release" && failNext) { + failNext = false; + throw new Error("client gone"); + } + return {}; + }); + const registry = new ClientTerminalRegistry(client.request, () => "sess-1"); + await registry.create({ command: "a" }); + await registry.create({ command: "b" }); + + // Runs on the way out, where there is nobody left to report a failure to. + await expect(registry.releaseAll()).resolves.toBeUndefined(); + expect(registry.size).toBe(0); + const releases = client.calls.filter((call) => call.method === "terminal/release"); + expect(releases).toHaveLength(2); +}); + +test("env record to pairs drops nothing and keeps order", () => { + expect(envRecordToPairs({ A: "1", B: "2" })).toEqual([ + { name: "A", value: "1" }, + { name: "B", value: "2" }, + ]); + expect(envRecordToPairs({})).toEqual([]); +}); 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 00000000..e7c451ac --- /dev/null +++ b/packages/coding-agent/test/acp-mode.test.ts @@ -0,0 +1,1406 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import type { Mock } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { AgentSessionEvent } from "../src/core/agent-session.ts"; +import type { AgentSessionRuntimeHost } from "../src/core/agent-session-runtime.ts"; +import type { ExtensionCommandContext, ExtensionUIContext } from "../src/core/extensions/index.ts"; +import { restoreStdout } from "../src/core/output-guard.ts"; +import { getDefaultSessionDir } from "../src/core/session-manager.ts"; +import { runAcpMode } from "../src/modes/acp/acp-mode.ts"; + +type FakeSession = { + sessionId: string; + agent: { subscribe: () => () => void }; + subscribe: (listener: (event: AgentSessionEvent) => void) => () => void; + bindExtensions: (bindings: { uiContext?: ExtensionUIContext }) => Promise; + prompt: Mock<(text: string) => Promise>; + abort: Mock<() => Promise>; + waitForIdle: Mock<() => Promise>; + navigateTree: Mock<() => Promise<{ cancelled: boolean }>>; + reload: Mock<() => Promise>; +}; + +type PermissionParams = { + sessionId: string; + toolCall: { title: string }; +}; + +// `session/delete` must answer exactly once when the file cannot be removed, +// which on Windows is the ordinary case of a session file still being held +// open. The spy delegates to the real unlink, so only the test that needs a +// failure replaces it and the rest of the suite is untouched. +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, unlink: vi.fn(actual.unlink) }; +}); + +let stdin: PassThrough; +let captured: string[]; +let stdoutSpy: Mock<(chunk: unknown, encodingOrCallback?: unknown, callback?: unknown) => boolean>; +let originalStdoutWrite: typeof process.stdout.write; +let originalStdin: typeof process.stdin; +let originalStepApiKey: string | undefined; +let originalAuthPath: string | undefined; +let originalAgentDir: string | undefined; +let agentDir: string; +let authDir: string; + +function createSession() { + let uiContext: ExtensionUIContext | undefined; + let sessionListener: ((event: AgentSessionEvent) => void) | undefined; + const session: FakeSession = { + sessionId: "session-1", + agent: { subscribe: vi.fn(() => () => {}) }, + subscribe: vi.fn((listener: (event: AgentSessionEvent) => void) => { + sessionListener = listener; + return () => { + sessionListener = undefined; + }; + }), + bindExtensions: vi.fn(async (bindings) => { + uiContext = bindings.uiContext; + }), + prompt: vi.fn(async () => {}), + abort: vi.fn(async () => {}), + waitForIdle: vi.fn(async () => {}), + navigateTree: vi.fn(async () => ({ cancelled: false })), + reload: vi.fn(async () => {}), + }; + return { + session, + getUIContext: () => uiContext, + /** Emit an engine event the way a real turn would. */ + emitSessionEvent: (event: AgentSessionEvent) => sessionListener?.(event), + }; +} + +function createHost(session: FakeSession): AgentSessionRuntimeHost { + return { + session, + cwd: process.cwd(), + setRebindSession: vi.fn(), + onSessionChange: vi.fn(() => () => {}), + setBeforeSessionInvalidate: vi.fn(), + switchSession: vi.fn(async () => ({ cancelled: false })), + newSession: vi.fn(async () => ({ cancelled: false })), + fork: vi.fn(async () => ({ cancelled: false })), + importFromJsonl: vi.fn(async () => ({ cancelled: false })), + dispose: vi.fn(async () => {}), + } as unknown as AgentSessionRuntimeHost; +} + +function send(value: unknown): void { + stdin.write(`${JSON.stringify(value)}\n`); +} + +function parsedLines(): Record[] { + const buffer = captured.join(""); + return buffer + .split("\n") + .slice(0, -1) + .filter((line) => line.length > 0) + .map((line) => { + try { + return JSON.parse(line) as Record; + } catch { + return {}; + } + }); +} + +async function waitForLine(predicate: (line: Record) => boolean): Promise> { + return vi.waitFor( + () => { + const line = parsedLines().find(predicate); + if (!line) { + throw new Error("expected JSON-RPC line not found"); + } + return line; + }, + { timeout: 5000, interval: 5 }, + ); +} + +async function waitForResponse(id: string | number): Promise> { + const line = await waitForLine( + (candidate) => candidate.id === id && ("result" in candidate || "error" in candidate), + ); + if (line.error) { + throw new Error(`JSON-RPC error for ${String(id)}: ${JSON.stringify(line.error)}`); + } + return line.result as Record; +} + +beforeEach(() => { + // The auth gate reads two credential sources. Point the file one at a + // directory that does not exist and hand out a key through the environment, + // so a credential left on the developer machine cannot decide whether a + // session is allowed to start. Tests that exercise the gate override this. + originalStepApiKey = process.env.STEP_API_KEY; + originalAuthPath = process.env.STEPCODE_AUTH_PATH; + authDir = join(tmpdir(), `step-code-acp-${process.pid}-${Math.random().toString(36).slice(2)}`); + process.env.STEPCODE_AUTH_PATH = join(authDir, "auth.json"); + process.env.STEP_API_KEY = "test-step-key"; + // session/fork resolves an id against every stored session, which means + // reading the whole agent directory. A real one holds hundreds of sessions, + // so point the tests at an empty one: "unknown session" has to be an + // assertion about the code path, never a race against the developer's data. + originalAgentDir = process.env.STEP_CODING_AGENT_DIR; + agentDir = join(authDir, "agent"); + process.env.STEP_CODING_AGENT_DIR = agentDir; + stdin = new PassThrough(); + captured = []; + originalStdin = process.stdin; + Object.defineProperty(process, "stdin", { + value: stdin, + configurable: true, + }); + originalStdoutWrite = process.stdout.write; + stdoutSpy = vi.fn((chunk: unknown, encodingOrCallback?: unknown, callback?: unknown) => { + captured.push(String(chunk)); + const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + if (typeof done === "function") { + (done as (error?: Error | null) => void)(null); + } + return true; + }); + process.stdout.write = stdoutSpy as unknown as typeof process.stdout.write; +}); + +afterEach(() => { + restoreStdout(); + rmSync(authDir, { recursive: true, force: true }); + if (originalStepApiKey === undefined) delete process.env.STEP_API_KEY; + else process.env.STEP_API_KEY = originalStepApiKey; + if (originalAuthPath === undefined) delete process.env.STEPCODE_AUTH_PATH; + else process.env.STEPCODE_AUTH_PATH = originalAuthPath; + if (originalAgentDir === undefined) delete process.env.STEP_CODING_AGENT_DIR; + else process.env.STEP_CODING_AGENT_DIR = originalAgentDir; + process.stdout.write = originalStdoutWrite; + stdin.destroy(); + Object.defineProperty(process, "stdin", { + value: originalStdin, + configurable: true, + }); +}); + +type FakeSessionWithControls = FakeSession & { + extensionRunner: { + getCommand: Mock< + (name: string) => { handler: Mock<(args: string, ctx: ExtensionCommandContext) => Promise> } | undefined + >; + createCommandContext: Mock<() => unknown>; + }; + modelRuntime: { + getAvailableSnapshot: Mock<() => Array<{ provider: string; id: string; name: string }>>; + /** Refreshed after a credential is written or cleared. */ + getAvailable: Mock<() => Promise>; + }; + model: { provider: string; id: string; name: string } | undefined; + setModel: Mock<(model: unknown) => Promise>; + supportsThinking: Mock<() => boolean>; + getAvailableThinkingLevels: Mock<() => string[]>; + thinkingLevel: string; + setThinkingLevel: Mock<(level: string) => void>; + /** The UI context the mode handed to `bindExtensions`, once it has. */ + getUIContext: () => ExtensionUIContext | undefined; +}; + +function createControlledSession(): FakeSessionWithControls { + const created = createSession(); + const base = created.session as unknown as FakeSessionWithControls; + // The step extension publishes the tier it just applied through the status + // channel, so the fake command does the same. Without it the mode has nothing + // to report and answers with the preset that was active before the call. + const labels: Record = { + ask: "Ask", + "approve-for-me": "Approve for Me", + "full-access": "Full Access", + }; + const permissionCommand = { + handler: vi.fn(async (args: string, ctx: { ui: ExtensionUIContext }) => { + const preset = labels[args.trim()]; + if (preset) ctx.ui.setStatus("step-permission", `Mode: ${preset}`); + }), + }; + base.extensionRunner = { + getCommand: vi.fn((name: string) => (name === "permissions" ? permissionCommand : undefined)), + createCommandContext: vi.fn(() => ({ ui: created.getUIContext() })), + }; + base.modelRuntime = { + getAvailableSnapshot: vi.fn(() => [ + { provider: "step", id: "step-3", name: "Step 3" }, + { provider: "step", id: "step-3-mini", name: "Step 3 Mini" }, + ]), + // authenticate and logout refresh the model list so the config-option + // selector reflects a credential that was just written or removed. + getAvailable: vi.fn(async () => []), + }; + base.model = { provider: "step", id: "step-3", name: "Step 3" }; + base.setModel = vi.fn(async () => {}); + base.supportsThinking = vi.fn(() => true); + base.getAvailableThinkingLevels = vi.fn(() => ["off", "low", "high"]); + base.thinkingLevel = "low"; + base.setThinkingLevel = vi.fn(() => {}); + // A controlled session still has to let a test reach the UI context the mode + // bound, which is how a permission prompt is driven. + base.getUIContext = created.getUIContext; + return base; +} + +type InitializeResult = { + protocolVersion: number; + agentCapabilities: { + promptCapabilities: { image: boolean; embeddedContext: boolean }; + mcpCapabilities?: { http?: boolean; sse?: boolean }; + sessionCapabilities: Record; + auth: Record; + }; + authMethods: Array<{ id: string; type?: string }>; +}; + +describe("acp initialization", () => { + test("advertises the session methods it implements and gates terminal auth on the client", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + const initialized = (await waitForResponse(1)) as unknown as InitializeResult; + + expect(initialized.agentCapabilities.promptCapabilities.embeddedContext).toBe(true); + // Both remote MCP transports the protocol defines are advertised, SSE included. + expect(initialized.agentCapabilities.mcpCapabilities).toEqual({ http: true, sse: true }); + expect(Object.keys(initialized.agentCapabilities.sessionCapabilities).sort()).toEqual([ + "close", + "delete", + "list", + "resume", + ]); + expect(initialized.agentCapabilities.auth).toEqual({ logout: {} }); + // Without terminal auth support the client only gets the agent method. + expect(initialized.authMethods).toEqual([ + { id: "step_api_key", name: "Step API key", description: "Paste an API key from the Step platform." }, + ]); + }); + + test("offers the terminal login method when the client can run it", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: 1, clientCapabilities: { auth: { terminal: true } } }, + }); + const initialized = (await waitForResponse(1)) as unknown as InitializeResult; + expect(initialized.authMethods.map((method) => method.id)).toEqual(["step_login", "step_api_key"]); + expect(initialized.authMethods[0]?.type).toBe("terminal"); + }); +}); + +describe("acp elicitation", () => { + test("bridges an extension input dialog and returns what the user typed", async () => { + const { session, getUIContext } = createSession(); + let typed: string | undefined; + session.prompt.mockImplementation(async () => { + typed = await getUIContext()?.input("Branch name?"); + }); + + void runAcpMode(createHost(session)); + send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: 1, clientCapabilities: { elicitation: { form: {} } } }, + }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/prompt", + params: { sessionId: "session-1", prompt: [{ type: "text", text: "rename" }] }, + }); + const elicitation = await waitForLine((line) => line.method === "elicitation/create"); + expect(elicitation.params).toMatchObject({ mode: "form", prompt: "Branch name?" }); + send({ jsonrpc: "2.0", id: elicitation.id, result: { action: "accept", content: { value: "feature/x" } } }); + + await vi.waitFor(() => { + expect(typed).toBe("feature/x"); + }); + }); + + test("resolves input as dismissed when the client declines", async () => { + const { session, getUIContext } = createSession(); + let answered = "unset"; + session.prompt.mockImplementation(async () => { + answered = String(await getUIContext()?.input("Branch name?")); + }); + + void runAcpMode(createHost(session)); + send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: 1, clientCapabilities: { elicitation: { form: {} } } }, + }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/prompt", + params: { sessionId: "session-1", prompt: [{ type: "text", text: "rename" }] }, + }); + const elicitation = await waitForLine((line) => line.method === "elicitation/create"); + send({ jsonrpc: "2.0", id: elicitation.id, result: { action: "decline" } }); + + await vi.waitFor(() => { + expect(answered).toBe("undefined"); + }); + }); + + test("returns the option the user picked from a select dialog", async () => { + const { session, getUIContext } = createSession(); + let picked: string | undefined; + session.prompt.mockImplementation(async () => { + picked = await getUIContext()?.select("Pick one", ["alpha", "beta"]); + }); + + void runAcpMode(createHost(session)); + send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: 1, clientCapabilities: { elicitation: { form: {} } } }, + }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/prompt", + params: { sessionId: "session-1", prompt: [{ type: "text", text: "choose" }] }, + }); + const elicitation = await waitForLine((line) => line.method === "elicitation/create"); + expect(elicitation.params).toMatchObject({ prompt: "Pick one" }); + send({ jsonrpc: "2.0", id: elicitation.id, result: { action: "accept", content: { choice: "beta" } } }); + + await vi.waitFor(() => { + expect(picked).toBe("beta"); + }); + }); +}); + +describe("acp mode", () => { + test("streams a prompt turn and resolves a permission request without blocking stdin", async () => { + const { session, getUIContext } = createSession(); + const host = createHost(session); + let releasePrompt = () => {}; + const promptDone = new Promise((resolve) => { + releasePrompt = resolve; + }); + session.prompt.mockImplementation(async () => { + const approved = await getUIContext()?.confirm("Approve?", "run bash"); + if (!approved) { + throw new Error("permission rejected"); + } + await promptDone; + }); + + void runAcpMode(host); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + const initialized = (await waitForResponse(1)) as unknown as InitializeResult; + expect(initialized.protocolVersion).toBe(1); + expect(initialized.agentCapabilities.promptCapabilities.image).toBe(true); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/prompt", + params: { sessionId: "session-1", prompt: [{ type: "text", text: "hello" }] }, + }); + const permission = await waitForLine((line) => line.method === "session/request_permission"); + const permissionId = permission.id; + expect(permissionId).toBeTypeOf("string"); + expect((permission.params as PermissionParams).toolCall.title).toBe("Approve?"); + send({ + jsonrpc: "2.0", + id: permissionId, + result: { outcome: { outcome: "selected", optionId: "allow_once" } }, + }); + releasePrompt(); + const result = await waitForResponse(2); + expect(result.stopReason).toBe("end_turn"); + }); + + test("reports a failed turn as a JSON-RPC error instead of end_turn", async () => { + const { session, emitSessionEvent } = createSession(); + const host = createHost(session); + session.prompt.mockImplementation(async () => { + // A failed model call does not reject prompt(); the engine records it on + // the assistant message and emits agent_end. + emitSessionEvent({ + type: "agent_end", + willRetry: false, + messages: [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { + role: "assistant", + content: [], + stopReason: "error", + errorMessage: "404: model unavailable", + }, + ], + } as unknown as AgentSessionEvent); + }); + + void runAcpMode(host); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 4, + method: "session/prompt", + params: { sessionId: "session-1", prompt: [{ type: "text", text: "hello" }] }, + }); + const line = await waitForLine((candidate) => candidate.id === 4); + expect(line.result).toBeUndefined(); + expect(line.error).toMatchObject({ code: -32603, message: expect.stringContaining("404") }); + }); + + test("does not replay an earlier failed turn onto a later successful one", async () => { + const { session, emitSessionEvent } = createSession(); + const host = createHost(session); + let turn = 0; + session.prompt.mockImplementation(async () => { + turn += 1; + emitSessionEvent({ + type: "agent_end", + willRetry: false, + messages: + turn === 1 + ? [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content: [], stopReason: "error", errorMessage: "boom" }, + ] + : [ + { role: "user", content: [{ type: "text", text: "again" }] }, + { role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop" }, + ], + } as unknown as AgentSessionEvent); + }); + + void runAcpMode(host); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 5, + method: "session/prompt", + params: { sessionId: "session-1", prompt: [{ type: "text", text: "hello" }] }, + }); + const failed = await waitForLine((candidate) => candidate.id === 5); + expect(failed.error).toBeDefined(); + + send({ + jsonrpc: "2.0", + id: 6, + method: "session/prompt", + params: { sessionId: "session-1", prompt: [{ type: "text", text: "again" }] }, + }); + const recovered = await waitForResponse(6); + expect(recovered.stopReason).toBe("end_turn"); + }); + + test("cancels an active prompt turn", async () => { + const { session } = createSession(); + const host = createHost(session); + let rejectPrompt: (error: Error) => void = () => {}; + session.prompt.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectPrompt = reject; + }), + ); + session.abort.mockImplementation(async () => { + rejectPrompt(new Error("aborted")); + }); + + void runAcpMode(host); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 3, + method: "session/prompt", + params: { sessionId: "session-1", prompt: [{ type: "text", text: "hello" }] }, + }); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledTimes(1); + }); + send({ jsonrpc: "2.0", method: "session/cancel" }); + const result = await waitForResponse(3); + expect(result.stopReason).toBe("cancelled"); + expect(session.abort).toHaveBeenCalledTimes(1); + }); + + describe("acp session modes and config options", () => { + test("session/new reports the modes and selectors the session can change", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd: process.cwd(), mcpServers: [] } }); + const created = (await waitForResponse(2)) as { + modes: { currentModeId: string; availableModes: Array<{ id: string }> }; + configOptions: Array<{ id: string; currentValue: string; options: Array<{ value: string }> }>; + }; + + expect(created.modes.availableModes.map((mode) => mode.id)).toEqual(["ask", "approve-for-me", "full-access"]); + // Nothing has published a tier yet, so the first preset is reported. + expect(created.modes.currentModeId).toBe("ask"); + expect(created.configOptions.map((option) => option.id)).toEqual(["model", "thinking"]); + expect(created.configOptions[0]?.currentValue).toBe("step/step-3"); + expect(created.configOptions[0]?.options.map((option) => option.value)).toContain("step/step-3-mini"); + }); + + test("session/set_mode runs the permissions command and answers with the new state", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/set_mode", + params: { sessionId: "session-1", modeId: "approve-for-me" }, + }); + const result = (await waitForResponse(2)) as { currentModeId: string }; + expect(session.extensionRunner.getCommand).toHaveBeenCalledWith("permissions"); + expect(result.currentModeId).toBe("approve-for-me"); + + const modeUpdate = await waitForLine( + (line) => + line.method === "session/update" && + JSON.stringify(line.params).includes('"sessionUpdate":"current_mode_update"'), + ); + expect(JSON.stringify(modeUpdate.params)).toContain('"currentModeId":"approve-for-me"'); + }); + + test("session/set_mode rejects an unknown mode without touching the policy", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/set_mode", + params: { sessionId: "session-1", modeId: "turbo" }, + }); + const error = await waitForLine((line) => line.id === 2 && "error" in line); + expect(error.error).toMatchObject({ code: -32602 }); + expect(session.extensionRunner.getCommand).not.toHaveBeenCalled(); + }); + + test("session/set_config_option switches the model and returns the selectors", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/set_config_option", + params: { sessionId: "session-1", optionId: "model", value: "step/step-3-mini" }, + }); + const result = (await waitForResponse(2)) as unknown as Array<{ id: string; currentValue: string }>; + expect(session.setModel).toHaveBeenCalledWith({ provider: "step", id: "step-3-mini", name: "Step 3 Mini" }); + // The response is built before the switch lands, so it still reports the + // model that was active when the request arrived. + expect(result[0]?.currentValue).toBe("step/step-3"); + }); + + test("session/set_config_option sets the reasoning level", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/set_config_option", + params: { sessionId: "session-1", optionId: "thinking", value: "high" }, + }); + await waitForResponse(2); + expect(session.setThinkingLevel).toHaveBeenCalledWith("high"); + }); + + test("session/set_config_option rejects an unknown option and an unknown model", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/set_config_option", + params: { sessionId: "session-1", optionId: "theme", value: "dark" }, + }); + expect(await waitForLine((line) => line.id === 2 && "error" in line)).toBeTruthy(); + + send({ + jsonrpc: "2.0", + id: 3, + method: "session/set_config_option", + params: { sessionId: "session-1", optionId: "model", value: "step/nope" }, + }); + expect(await waitForLine((line) => line.id === 3 && "error" in line)).toBeTruthy(); + expect(session.setModel).not.toHaveBeenCalled(); + }); + }); + + describe("acp methods outside the v1 schema", () => { + // Both are extensions the v1 schema does not define. They are accepted + // because clients that talk to agents which implement them will send them, + // and each is an alias over a method that is in the schema. + + test("session/set_model is the model half of session/set_config_option", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/set_model", + params: { sessionId: "session-1", modelId: "step/step-3-mini" }, + }); + const result = (await waitForResponse(2)) as unknown as Array<{ id: string; currentValue: string }>; + expect(session.setModel).toHaveBeenCalledWith({ provider: "step", id: "step-3-mini", name: "Step 3 Mini" }); + // Same response shape as the method it aliases. + expect(result[0]?.id).toBe("model"); + }); + + test("session/set_model rejects an unknown model and a foreign session", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/set_model", + params: { sessionId: "session-1", modelId: "step/nope" }, + }); + expect(await waitForLine((line) => line.id === 2 && "error" in line)).toBeTruthy(); + + send({ + jsonrpc: "2.0", + id: 3, + method: "session/set_model", + params: { sessionId: "someone-else", modelId: "step/step-3-mini" }, + }); + expect(await waitForLine((line) => line.id === 3 && "error" in line)).toBeTruthy(); + expect(session.setModel).not.toHaveBeenCalled(); + }); + + test("session/fork reports an unknown session instead of forking nothing", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/fork", + params: { sessionId: "no-such-session", cwd: process.cwd() }, + }); + // The source must resolve to a real session file before anything is + // written, so a bad id fails rather than creating an empty fork. + expect(await waitForLine((line) => line.id === 2 && "error" in line)).toBeTruthy(); + }); + }); + + describe("acp session authentication gate", () => { + // Every session here uses the controlled factory, whose active model is a + // Step model. That is the shape of a default install, which is the only + // case the gate covers. + + test("refuses session/new with auth_required when nothing can authenticate it", async () => { + delete process.env.STEP_API_KEY; + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd: process.cwd(), mcpServers: [] } }); + const error = await waitForLine((line) => line.id === 2 && "error" in line); + expect(error.error).toMatchObject({ code: -32000 }); + // The message has to name the fix, because that is the whole point of + // answering with auth_required instead of letting the turn fail. + expect((error.error as { message?: string }).message).toContain("STEP_API_KEY"); + }); + + test("starts the session when a key is in the environment", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd: process.cwd(), mcpServers: [] } }); + const created = await waitForResponse(2); + expect(created.sessionId).toBe("session-1"); + }); + + test("starts the session when step login left a credential on disk", async () => { + delete process.env.STEP_API_KEY; + mkdirSync(authDir, { recursive: true }); + writeFileSync(join(authDir, "auth.json"), JSON.stringify({ step: { type: "api_key", key: "stored-key" } })); + + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd: process.cwd(), mcpServers: [] } }); + const created = await waitForResponse(2); + expect(created.sessionId).toBe("session-1"); + }); + + test("does not gate a session whose active model is not a Step model", async () => { + delete process.env.STEP_API_KEY; + const session = createControlledSession(); + session.model = { provider: "openai", id: "gpt-4o", name: "GPT-4o" }; + + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd: process.cwd(), mcpServers: [] } }); + const created = await waitForResponse(2); + expect(created.sessionId).toBe("session-1"); + }); + }); +}); + +describe("acp session-scoped permission answers", () => { + /** + * Every `session/request_permission` line the mode has emitted so far, in + * order. Indexing by position is required: an "always" answer stops further + * requests, so a plain `find` would keep matching the request that was + * already answered and the test would silently answer a stale id. + */ + function permissionRequests(): Array<{ id: unknown; options: Array<{ optionId: string; kind: string }> }> { + return parsedLines() + .filter((line) => line.method === "session/request_permission") + .map((line) => ({ + id: line.id, + options: (line.params as { options: Array<{ optionId: string; kind: string }> }).options, + })); + } + + async function answer(index: number, optionId: string): Promise { + const request = permissionRequests()[index]; + if (!request) throw new Error(`no permission request #${index + 1} was raised`); + send({ jsonrpc: "2.0", id: request.id, result: { outcome: { outcome: "selected", optionId } } }); + } + + const prompt = (id: number, text = "hello") => + send({ + jsonrpc: "2.0", + id, + method: "session/prompt", + params: { sessionId: "session-1", prompt: [{ type: "text", text }] }, + }); + + test("offers both the one-shot and the session-wide answer on every request", async () => { + const { session, getUIContext } = createSession(); + session.prompt.mockImplementation(async () => { + await getUIContext()?.confirm("Approve?", "run bash"); + }); + + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + prompt(2); + await vi.waitFor(() => expect(permissionRequests()).toHaveLength(1)); + // The kinds are what a spec-following client renders, so they matter as + // much as the ids: `allow_always` is the one the protocol reserves for + // "stop asking for the rest of this session". + expect(permissionRequests()[0]?.options).toEqual([ + { optionId: "allow_once", name: "Allow once", kind: "allow_once" }, + { optionId: "allow_always", name: "Allow for this session", kind: "allow_always" }, + { optionId: "reject_once", name: "Reject once", kind: "reject_once" }, + { optionId: "reject_always", name: "Reject for this session", kind: "reject_always" }, + ]); + }); + + test("settles later requests without a round-trip once the user answered always", async () => { + const { session, getUIContext } = createSession(); + const answers: boolean[] = []; + session.prompt.mockImplementation(async () => { + answers.push((await getUIContext()?.confirm("Approve?", "run bash")) ?? false); + }); + + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + prompt(2); + await vi.waitFor(() => expect(permissionRequests()).toHaveLength(1)); + await answer(0, "allow_always"); + await waitForResponse(2); + + prompt(3, "hello again"); + await waitForResponse(3); + + expect(answers).toEqual([true, true]); + // Counting the requests catches a flag that is recorded but never read. + expect(permissionRequests()).toHaveLength(1); + }); + + test("stops asking after a reject-always answer", async () => { + const { session, getUIContext } = createSession(); + const answers: boolean[] = []; + session.prompt.mockImplementation(async () => { + answers.push((await getUIContext()?.confirm("Approve?", "run bash")) ?? false); + }); + + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + prompt(2); + await vi.waitFor(() => expect(permissionRequests()).toHaveLength(1)); + await answer(0, "reject_always"); + await waitForResponse(2); + + prompt(3, "hello again"); + await waitForResponse(3); + + expect(answers).toEqual([false, false]); + expect(permissionRequests()).toHaveLength(1); + }); + + test("reads a reject-always answer as consent withheld when both flags are set", async () => { + const { session, getUIContext } = createSession(); + const answers: boolean[] = []; + // Two tool calls in one turn ask at the same time. The client answers + // them out of order, which is the only way both flags can end up set: + // once either one lands the mode stops asking, so the client cannot + // reach the other through a later turn. + let releaseBoth = () => {}; + const bothAsked = new Promise((resolve) => { + releaseBoth = resolve; + }); + session.prompt.mockImplementation(async () => { + const first = getUIContext()?.confirm("Approve?", "run bash"); + const second = getUIContext()?.confirm("Approve?", "run bash"); + releaseBoth(); + answers.push((await first) ?? false); + answers.push((await second) ?? false); + }); + + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + prompt(2); + await bothAsked; + await vi.waitFor(() => expect(permissionRequests()).toHaveLength(2)); + await answer(0, "reject_always"); + await answer(1, "allow_always"); + await waitForResponse(2); + + // The first answer is a rejection. The second was already in flight when + // the flag landed, so it keeps whatever the client said for it. + expect(answers).toEqual([false, true]); + + // Back to one guarded call: the concurrency above was only a way to get + // both flags set, and this turn is what actually settles the precedence. + session.prompt.mockImplementation(async () => { + answers.push((await getUIContext()?.confirm("Approve?", "run bash")) ?? false); + }); + prompt(3, "hello again"); + await waitForResponse(3); + // A client that somehow selected both must not be read as consent. + expect(answers.slice(2)).toEqual([false]); + expect(permissionRequests()).toHaveLength(2); + }); + + test("asks again after the active session changes", async () => { + const session = createControlledSession(); + const answers: boolean[] = []; + session.prompt.mockImplementation(async () => { + answers.push((await session.getUIContext()?.confirm("Approve?", "run bash")) ?? false); + }); + + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + prompt(2); + await vi.waitFor(() => expect(permissionRequests()).toHaveLength(1)); + await answer(0, "allow_always"); + await waitForResponse(2); + + // A new session is a new consent scope, and the agent's session id is + // unchanged here, so only the switch can clear the answer. + send({ jsonrpc: "2.0", id: 3, method: "session/new", params: { cwd: process.cwd(), mcpServers: [] } }); + await waitForResponse(3); + + prompt(4, "hello again"); + await vi.waitFor(() => expect(permissionRequests()).toHaveLength(2)); + await answer(1, "allow_once"); + await waitForResponse(4); + + expect(answers).toEqual([true, true]); + }); +}); + +describe("acp additionalDirectories reporting", () => { + test("reports workspace roots it cannot honour and still starts the session", async () => { + const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true); + try { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/new", + params: { cwd: process.cwd(), mcpServers: [], additionalDirectories: ["/repo/packages", "/repo/apps"] }, + }); + // Refusing would strand the client, so the session starts and the gap + // is reported instead. + const created = await waitForResponse(2); + expect(created.sessionId).toBe("session-1"); + + const written = stderr.mock.calls.map((call) => String(call[0])).join(""); + expect(written).toContain("[acp] additionalDirectories is not honored"); + expect(written).toContain("/repo/packages"); + expect(written).toContain("/repo/apps"); + } finally { + stderr.mockRestore(); + } + }); + + test("stays silent when the client grants no extra roots", async () => { + const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true); + try { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd: process.cwd(), mcpServers: [] } }); + await waitForResponse(2); + send({ jsonrpc: "2.0", id: 3, method: "session/new", params: { cwd: process.cwd(), mcpServers: [] } }); + await waitForResponse(3); + + const written = stderr.mock.calls.map((call) => String(call[0])).join(""); + // A warning on every session switch would be noise that trains the + // user to ignore stderr. + expect(written).not.toContain("additionalDirectories"); + } finally { + stderr.mockRestore(); + } + }); + + test("rejects a malformed additionalDirectories list instead of starting blind", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/new", + params: { cwd: process.cwd(), mcpServers: [], additionalDirectories: [42] }, + }); + const error = await waitForLine((line) => line.id === 2 && "error" in line); + expect((error.error as { code?: number }).code).toBe(-32602); + }); +}); + +/** + * Write a session file where the manager looks for one. + * + * The lifecycle handlers resolve an id against what is on disk, so these tests + * need a real file rather than a stubbed manager: the point is that a stored + * session is genuinely found, switched to, replayed and deleted. + */ +async function writeStoredSession( + cwd: string, + entries: ReadonlyArray> = [], +): Promise<{ sessionId: string; path: string }> { + const sessionId = randomUUID(); + const dir = getDefaultSessionDir(cwd); + mkdirSync(dir, { recursive: true }); + const path = join(dir, `2026-01-01T00-00-00-000Z_${sessionId}.jsonl`); + const lines = [ + { type: "session", version: 3, id: sessionId, timestamp: "2026-01-01T00:00:00.000Z", cwd }, + ...entries, + ]; + writeFileSync(path, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + return { sessionId, path }; +} + +function storedMessage( + role: "user" | "assistant", + content: ReadonlyArray>, +): Record { + return { + type: "message", + id: randomUUID(), + parentId: null, + timestamp: "2026-01-01T00:00:01.000Z", + message: { role, content, timestamp: 1767225601000 }, + }; +} + +function sessionUpdates(): Array> { + return parsedLines() + .filter((line) => line.method === "session/update") + .map((line) => (line.params as { update?: Record }).update ?? {}); +} + +describe("acp session lifecycle methods", () => { + // Every method here resolves a session id against the store, so they share + // one fixture: a real session file under the isolated agent directory. + + test("session/list reports the stored sessions of one project", async () => { + const stored = await writeStoredSession(process.cwd(), [ + storedMessage("user", [{ type: "text", text: "hello there" }]), + ]); + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/list", params: { cwd: process.cwd() } }); + const result = await waitForResponse(2); + const sessions = result.sessions as Array>; + const listed = sessions.find((entry) => entry.sessionId === stored.sessionId); + expect(listed).toBeDefined(); + expect(listed?.cwd).toBe(process.cwd()); + // The count travels in _meta because v1 has no field for it. + expect((listed?._meta as { messageCount?: number }).messageCount).toBe(1); + }); + + test("session/list rejects a cursor it did not issue instead of replaying page one", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/list", params: { cwd: process.cwd(), cursor: "not-a-cursor" } }); + const error = await waitForLine((line) => line.id === 2 && "error" in line); + expect((error.error as { code?: number }).code).toBe(-32602); + // Restarting from the first page would hand back sessions the client + // already has, so the malformed cursor has to fail loudly. + }); + + test("session/list pages through a store larger than one page", async () => { + // One more than a page, so the boundary is actually crossed rather than + // assumed. + const stored = []; + for (let i = 0; i < 101; i++) { + stored.push(await writeStoredSession(process.cwd())); + } + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/list", params: { cwd: process.cwd() } }); + const first = await waitForResponse(2); + const firstPage = first.sessions as Array>; + expect(firstPage).toHaveLength(100); + // The cursor is what lets a client keep going; without it the last + // session is unreachable. + const cursor = first.nextCursor as string; + expect(cursor).toMatch(/^\d+$/); + + send({ jsonrpc: "2.0", id: 3, method: "session/list", params: { cwd: process.cwd(), cursor } }); + const second = await waitForResponse(3); + const secondPage = second.sessions as Array>; + expect(secondPage).toHaveLength(1); + // No cursor on the last page is how the protocol says "no more". + expect(second.nextCursor).toBeUndefined(); + // The two pages must not overlap, or paging replays sessions. + const seen = new Set(firstPage.map((entry) => entry.sessionId)); + expect(secondPage.every((entry) => !seen.has(entry.sessionId))).toBe(true); + }); + + test("session/list answers an empty list for a project with no sessions", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/list", params: { cwd: process.cwd() } }); + const result = await waitForResponse(2); + expect(result.sessions).toEqual([]); + }); + + test("session/load reports an unknown session instead of switching", async () => { + const session = createControlledSession(); + const host = createHost(session); + void runAcpMode(host); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/load", + params: { sessionId: "no-such-session", cwd: process.cwd(), mcpServers: [] }, + }); + const error = await waitForLine((line) => line.id === 2 && "error" in line); + expect((error.error as { message?: string }).message).toContain("Session not found"); + // Switching to nothing would silently drop the caller's session. + expect(host.switchSession).not.toHaveBeenCalled(); + }); + + test("session/load switches to a stored session and answers with its selectors", async () => { + const stored = await writeStoredSession(process.cwd()); + const session = createControlledSession(); + const host = createHost(session); + void runAcpMode(host); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/load", + params: { sessionId: stored.sessionId, cwd: process.cwd(), mcpServers: [] }, + }); + const result = await waitForResponse(2); + expect(host.switchSession).toHaveBeenCalledWith(stored.path, { cwdOverride: process.cwd() }); + // The client needs the selectors to render the session it just loaded. + expect(result.modes).toBeDefined(); + expect(Array.isArray(result.configOptions)).toBe(true); + }); + + test("session/load refuses a session stored under another working directory", async () => { + const otherCwd = join(process.cwd(), "src"); + const stored = await writeStoredSession(otherCwd); + const session = createControlledSession(); + const host = createHost(session); + void runAcpMode(host); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/load", + params: { sessionId: stored.sessionId, cwd: process.cwd(), mcpServers: [] }, + }); + const error = await waitForLine((line) => line.id === 2 && "error" in line); + expect((error.error as { message?: string }).message).toContain("cwd does not match"); + expect(host.switchSession).not.toHaveBeenCalled(); + }); + + test("session/resume reports an unknown session instead of resuming", async () => { + const session = createControlledSession(); + const host = createHost(session); + void runAcpMode(host); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/resume", + params: { sessionId: "no-such-session", cwd: process.cwd(), mcpServers: [] }, + }); + const error = await waitForLine((line) => line.id === 2 && "error" in line); + expect((error.error as { message?: string }).message).toContain("Session not found"); + expect(host.switchSession).not.toHaveBeenCalled(); + }); + + test("session/resume never replays the stored transcript", async () => { + const stored = await writeStoredSession(process.cwd(), [ + storedMessage("user", [{ type: "text", text: "what changed?" }]), + storedMessage("assistant", [ + { type: "thinking", thinking: "checking the diff" }, + { type: "text", text: "Two files changed." }, + ]), + ]); + const session = createControlledSession(); + const host = createHost(session); + void runAcpMode(host); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/resume", + params: { + sessionId: stored.sessionId, + cwd: process.cwd(), + mcpServers: [], + // A client that asks anyway gets the spec answer, not the transcript: + // resume reattaches to a session the client is already showing. + replayFrom: { type: "start" }, + }, + }); + await waitForResponse(2); + expect(sessionUpdates()).toEqual([]); + expect(host.switchSession).toHaveBeenCalledWith(stored.path, { cwdOverride: process.cwd() }); + }); + + test("session/load replays the stored transcript before it answers", async () => { + const stored = await writeStoredSession(process.cwd(), [ + storedMessage("user", [{ type: "text", text: "what changed?" }]), + storedMessage("assistant", [ + { type: "thinking", thinking: "checking the diff" }, + { type: "text", text: "Two files changed." }, + ]), + ]); + const session = createControlledSession(); + const host = createHost(session); + void runAcpMode(host); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ + jsonrpc: "2.0", + id: 2, + method: "session/load", + params: { sessionId: stored.sessionId, cwd: process.cwd(), mcpServers: [] }, + }); + await waitForResponse(2); + // v1 has no image chunk kind, so an image part would arrive as a text + // placeholder; text and thinking parts must arrive verbatim. + expect(sessionUpdates()).toEqual([ + { sessionUpdate: "user_message_chunk", content: { type: "text", text: "what changed?" } }, + { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "checking the diff" } }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Two files changed." } }, + ]); + expect(host.switchSession).toHaveBeenCalledWith(stored.path, { cwdOverride: process.cwd() }); + }); + + test("session/delete refuses to delete the active session", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/delete", params: { sessionId: "session-1" } }); + const error = await waitForLine((line) => line.id === 2 && "error" in line); + expect((error.error as { message?: string }).message).toContain("Cannot delete the active session"); + }); + + test("session/delete reports an unknown session", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/delete", params: { sessionId: "no-such-session" } }); + const error = await waitForLine((line) => line.id === 2 && "error" in line); + expect((error.error as { message?: string }).message).toContain("Session not found"); + }); + + test("session/delete removes a stored session file and answers once", async () => { + const stored = await writeStoredSession(process.cwd()); + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/delete", params: { sessionId: stored.sessionId } }); + await waitForResponse(2); + expect(existsSync(stored.path)).toBe(false); + // One request must get exactly one reply. The failure path used to answer + // twice: the error and then the success. + const replies = parsedLines().filter((line) => line.id === 2); + expect(replies).toHaveLength(1); + }); + + test("session/delete answers once with an error when the file cannot be removed", async () => { + const stored = await writeStoredSession(process.cwd()); + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + // A locked or permission-denied file is the realistic failure, and + // it used to produce two replies: the error, then the success. + vi.mocked(unlink).mockRejectedValueOnce(new Error("EBUSY: resource busy or locked")); + + send({ jsonrpc: "2.0", id: 2, method: "session/delete", params: { sessionId: stored.sessionId } }); + const error = await waitForLine((line) => line.id === 2 && "error" in line); + expect((error.error as { message?: string }).message).toContain("Failed to delete session"); + // The file is still there, so the client is told it failed rather than + // left believing a session it can still load was gone. + expect(existsSync(stored.path)).toBe(true); + const replies = parsedLines().filter((line) => line.id === 2); + expect(replies).toHaveLength(1); + expect("error" in replies[0]).toBe(true); + }); + + test("session/close refuses a sessionId that is not the active one", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/close", params: { sessionId: "some-other-session" } }); + const error = await waitForLine((line) => line.id === 2 && "error" in line); + expect((error.error as { message?: string }).message).toContain("Unknown sessionId"); + expect(session.abort).not.toHaveBeenCalled(); + }); + + test("session/close cancels the turn but keeps the session on disk", async () => { + const stored = await writeStoredSession(process.cwd()); + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "session/close", params: { sessionId: "session-1" } }); + await waitForResponse(2); + expect(session.abort).toHaveBeenCalled(); + // Closing is not deleting: a later load or resume must still find it. + expect(existsSync(stored.path)).toBe(true); + }); + + test("logout clears the stored credential and answers", async () => { + const session = createControlledSession(); + void runAcpMode(createHost(session)); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1 } }); + await waitForResponse(1); + + send({ jsonrpc: "2.0", id: 2, method: "logout", params: {} }); + await waitForResponse(2); + // The selector has to drop the credential that is no longer there. + expect(session.modelRuntime.getAvailable).toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/acp-wire.test.ts b/packages/coding-agent/test/acp-wire.test.ts new file mode 100644 index 00000000..88b22e25 --- /dev/null +++ b/packages/coding-agent/test/acp-wire.test.ts @@ -0,0 +1,608 @@ +import { describe, expect, test } from "vitest"; +import type { AgentSessionEvent } from "../src/core/agent-session.ts"; +import { + convertAcpMcpServers, + extractTurnError, + mapAssistantEventToSessionUpdate, + normalizePrompt, + parseElicitationResult, + parseJsonRpcLine, + parseRequestPermissionResult, + toSessionNotification, + validateForkSessionParams, + validateInitializeParams, + validateListSessionsParams, + validateLoadSessionParams, + validateNewSessionParams, + validatePromptParams, + validateReadTextFileParams, + validateResumeSessionParams, + validateSetSessionConfigOptionParams, + validateSetSessionModelParams, + validateSetSessionModeParams, + validateTerminalCreateParams, + validateTerminalKillParams, + validateTerminalOutputParams, + validateTerminalReleaseParams, + validateTerminalWaitForExitParams, + validateWriteTextFileParams, +} from "../src/modes/acp/acp-wire.ts"; + +describe("acp wire parsing", () => { + test("parses a JSON-RPC request", () => { + const parsed = parseJsonRpcLine('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}'); + expect(parsed.error).toBeUndefined(); + expect(parsed.request).toEqual({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: 1 }, + }); + }); + + test("parses a JSON-RPC response without treating it as a request", () => { + const parsed = parseJsonRpcLine('{"jsonrpc":"2.0","id":"abc","result":{"outcome":{"outcome":"cancelled"}}}'); + expect(parsed.error).toBeUndefined(); + expect(parsed.request).toBeUndefined(); + expect(parsed.response).toEqual({ + jsonrpc: "2.0", + id: "abc", + result: { outcome: { outcome: "cancelled" } }, + }); + }); + + test("reports invalid JSON", () => { + expect(parseJsonRpcLine("{").error).toMatchObject({ code: -32700 }); + }); +}); + +describe("acp parameter validation", () => { + test("accepts initialize params", () => { + expect(validateInitializeParams({ protocolVersion: 1 }).ok).toBe(true); + expect(validateInitializeParams({}).ok).toBe(false); + }); + + test("requires an absolute cwd for session lifecycle requests", () => { + expect(validateNewSessionParams({ cwd: "/repo", mcpServers: [] }).ok).toBe(true); + expect(validateNewSessionParams({ cwd: "repo", mcpServers: [] }).ok).toBe(false); + expect(validateLoadSessionParams({ sessionId: "s1", cwd: "/repo", mcpServers: [] }).ok).toBe(true); + expect(validateLoadSessionParams({ sessionId: "s1", cwd: "repo", mcpServers: [] }).ok).toBe(false); + expect(validateResumeSessionParams({ sessionId: "s1", cwd: "/repo", mcpServers: [] }).ok).toBe(true); + expect(validateResumeSessionParams({ sessionId: "s1", cwd: "repo", mcpServers: [] }).ok).toBe(false); + // Fork was the one lifecycle method that took a relative cwd. It writes the + // new session under that path and matches it against the source session's + // stored cwd, so a relative one resolves against wherever the agent happens + // to run and then fails somewhere the client never named. + expect(validateForkSessionParams({ sessionId: "s1", cwd: "/repo" }).ok).toBe(true); + expect(validateForkSessionParams({ sessionId: "s1", cwd: "repo" }).ok).toBe(false); + expect(validateListSessionsParams({ cwd: "/repo" }).ok).toBe(true); + expect(validateListSessionsParams({ cwd: "repo" }).ok).toBe(false); + }); + + test("validates client-side file requests", () => { + expect(validateReadTextFileParams({ sessionId: "s1", path: "/repo/a.ts" }).ok).toBe(true); + expect(validateReadTextFileParams({ sessionId: "s1" }).ok).toBe(false); + expect(validateReadTextFileParams({ path: "/repo/a.ts" }).ok).toBe(false); + const ranged = validateReadTextFileParams({ sessionId: "s1", path: "/repo/a.ts", line: 10, limit: 5 }); + expect(ranged.ok && ranged.value).toMatchObject({ line: 10, limit: 5 }); + + expect(validateWriteTextFileParams({ sessionId: "s1", path: "/repo/a.ts", content: "x" }).ok).toBe(true); + expect(validateWriteTextFileParams({ sessionId: "s1", path: "/repo/a.ts" }).ok).toBe(false); + expect(validateWriteTextFileParams({ sessionId: "s1", path: "/repo/a.ts", content: 1 }).ok).toBe(false); + }); + + test("validates terminal requests and drops malformed env and args", () => { + expect(validateTerminalCreateParams({ sessionId: "s1", command: "npm" }).ok).toBe(true); + expect(validateTerminalCreateParams({ sessionId: "s1" }).ok).toBe(false); + const created = validateTerminalCreateParams({ + sessionId: "s1", + command: "npm", + args: ["test", 3, null], + env: [{ name: "A", value: "1" }, { name: "B" }, "junk"], + cwd: "/repo", + outputByteLimit: 1024, + }); + expect(created.ok && created.value).toEqual({ + sessionId: "s1", + command: "npm", + args: ["test"], + env: [{ name: "A", value: "1" }], + cwd: "/repo", + outputByteLimit: 1024, + }); + + for (const validate of [ + validateTerminalOutputParams, + validateTerminalWaitForExitParams, + validateTerminalReleaseParams, + validateTerminalKillParams, + ]) { + expect(validate({ sessionId: "s1", terminalId: "t1" }).ok).toBe(true); + expect(validate({ sessionId: "s1" }).ok).toBe(false); + expect(validate({ terminalId: "t1" }).ok).toBe(false); + } + }); + + test("validates the two methods outside the v1 schema", () => { + expect(validateForkSessionParams({ sessionId: "s1", cwd: "/repo" }).ok).toBe(true); + expect(validateForkSessionParams({ sessionId: "s1" }).ok).toBe(false); + const forked = validateForkSessionParams({ + sessionId: "s1", + cwd: "/repo", + mcpServers: [], + additionalDirectories: [], + }); + expect(forked.ok && forked.value).toMatchObject({ sessionId: "s1", cwd: "/repo", mcpServers: [] }); + + expect(validateSetSessionModelParams({ sessionId: "s1", modelId: "step/step-3" }).ok).toBe(true); + expect(validateSetSessionModelParams({ sessionId: "s1" }).ok).toBe(false); + expect(validateSetSessionModelParams({ sessionId: "s1", modelId: "" }).ok).toBe(false); + }); + + test("validates additionalDirectories the same way on every session-entry method", () => { + const base = { sessionId: "s1", cwd: "/repo", mcpServers: [] }; + const bad: unknown[] = [["/repo", ""], ["", "/repo"], 42, "nope", { root: "/repo" }]; + for (const additionalDirectories of bad) { + expect(validateNewSessionParams({ ...base, additionalDirectories }).ok).toBe(false); + expect(validateLoadSessionParams({ ...base, additionalDirectories }).ok).toBe(false); + expect(validateResumeSessionParams({ ...base, additionalDirectories }).ok).toBe(false); + } + // Absent normalizes to an empty list rather than staying undefined, so a + // caller never has to branch on whether the client sent the field. + const empty = (parsed: unknown): unknown => + parsed && typeof parsed === "object" && "value" in parsed + ? (parsed as { value: { additionalDirectories?: unknown } }).value.additionalDirectories + : (parsed as { additionalDirectories?: unknown }).additionalDirectories; + for (const validate of [validateNewSessionParams, validateLoadSessionParams, validateResumeSessionParams]) { + const parsed = validate(base); + expect(parsed.ok).toBe(true); + expect(empty(parsed)).toEqual([]); + } + const withRoots = validateNewSessionParams({ ...base, additionalDirectories: ["/repo/packages"] }); + expect(withRoots.ok && withRoots.additionalDirectories).toEqual(["/repo/packages"]); + }); + + test("accepts baseline prompt content and rejects unsupported blocks", () => { + const valid = validatePromptParams({ + sessionId: "s1", + prompt: [ + { type: "text", text: "hello" }, + { type: "resource_link", name: "file", uri: "file:///repo/file" }, + { type: "image", data: "abc", mimeType: "image/png" }, + ], + }); + expect(valid.ok).toBe(true); + expect(validatePromptParams({ sessionId: "s1", prompt: [{ type: "audio" }] }).ok).toBe(false); + }); +}); + +describe("acp prompt normalization", () => { + test("joins text and resource links and keeps images", () => { + const normalized = normalizePrompt([ + { type: "text", text: "first" }, + { type: "resource_link", name: "file", uri: "file:///repo/file" }, + { type: "text", text: "second" }, + { type: "image", data: "abc", mimeType: "image/png" }, + ]); + expect(normalized.text).toBe("first\n[[file]](file:///repo/file)\nsecond"); + expect(normalized.images).toEqual([{ type: "image", data: "abc", mimeType: "image/png" }]); + }); +}); + +describe("acp session updates", () => { + test("maps streamed text and thinking deltas", () => { + const textEvent = { + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "hello", partial: {} }, + } as unknown as AgentSessionEvent; + expect(mapAssistantEventToSessionUpdate(textEvent)).toEqual({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello" }, + }); + + const thinkingEvent = { + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta: "think", partial: {} }, + } as unknown as AgentSessionEvent; + expect(mapAssistantEventToSessionUpdate(thinkingEvent)).toEqual({ + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "think" }, + }); + }); + + test("maps tool execution lifecycle and redacts obvious secrets", () => { + const start = { + type: "tool_execution_start", + toolCallId: "call-1", + toolName: "bash", + args: { command: "echo hi", apiToken: "secret" }, + } as unknown as AgentSessionEvent; + expect(mapAssistantEventToSessionUpdate(start)).toEqual({ + sessionUpdate: "tool_call", + toolCallId: "call-1", + title: "bash echo hi", + name: "bash", + kind: "execute", + status: "pending", + content: [], + rawInput: { command: "echo hi", apiToken: "[redacted]" }, + }); + + const end = { + type: "tool_execution_end", + toolCallId: "call-1", + toolName: "bash", + result: "boom", + isError: true, + } as unknown as AgentSessionEvent; + expect(mapAssistantEventToSessionUpdate(end)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "failed", + rawOutput: { error: "boom" }, + }); + }); + + test("reports a running tool as in_progress instead of skipping to its outcome", () => { + const update = { + type: "tool_execution_update", + toolCallId: "call-1", + toolName: "bash", + args: {}, + partialResult: "half a line", + } as unknown as AgentSessionEvent; + // The protocol asks for in_progress once the tool runs, and this is the + // only event between start and end. Without it the client watches a call + // sit at pending and then jump to its outcome. + expect(mapAssistantEventToSessionUpdate(update)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: "half a line" } }], + }); + + // A tool that reports no partial output still gets the status. + const silent = { ...update, partialResult: undefined } as unknown as AgentSessionEvent; + expect(mapAssistantEventToSessionUpdate(silent)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "in_progress", + }); + + // null renders as the string "null" if it is treated as a value, so it + // has to be filtered the same way undefined is. + const nullish = { ...update, partialResult: null } as unknown as AgentSessionEvent; + expect(mapAssistantEventToSessionUpdate(nullish)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "in_progress", + }); + }); + + test("reports what a completed tool produced", () => { + const end = { + type: "tool_execution_end", + toolCallId: "call-1", + toolName: "read", + result: "file body", + isError: false, + } as unknown as AgentSessionEvent; + // Success used to carry nothing, so a client could show that a tool + // finished but never what it did, which is the case it most needs. + expect(mapAssistantEventToSessionUpdate(end)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "file body" } }], + }); + + // A structured result is rendered rather than dropped. + const structured = { ...end, result: { lines: 3 } } as unknown as AgentSessionEvent; + expect(mapAssistantEventToSessionUpdate(structured)).toMatchObject({ + status: "completed", + content: [{ type: "content", content: { type: "text", text: '{"lines":3}' } }], + }); + + // Nothing to report stays absent rather than becoming an empty block. + const empty = { ...end, result: undefined } as unknown as AgentSessionEvent; + expect(mapAssistantEventToSessionUpdate(empty)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "completed", + }); + }); + + test("bounds the tool output it sends so one call cannot flood the client", () => { + const end = { + type: "tool_execution_end", + toolCallId: "call-1", + toolName: "read", + result: "x".repeat(5000), + isError: false, + } as unknown as AgentSessionEvent; + const update = mapAssistantEventToSessionUpdate(end); + const text = (update as { content?: Array<{ content: { text: string } }> }).content?.[0]?.content.text ?? ""; + // Truncated, and says so, so a client does not present a partial read as + // the whole result. The model still receives the untruncated text. + expect(text.length).toBeLessThan(5000); + expect(text).toContain("[truncated, 5000 characters total]"); + }); + + test("wraps updates in a session notification", () => { + const event = { + type: "tool_execution_start", + toolCallId: "call-1", + toolName: "read", + args: { path: "/repo/file" }, + } as unknown as AgentSessionEvent; + expect(toSessionNotification("session-1", event)).toEqual({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "call-1", + title: "read /repo/file", + name: "read", + kind: "read", + status: "pending", + content: [], + rawInput: { path: "/repo/file" }, + }, + }); + }); +}); + +describe("acp permission results", () => { + test("parses selected and cancelled outcomes", () => { + expect(parseRequestPermissionResult({ outcome: { outcome: "cancelled" } })).toEqual({ + outcome: { outcome: "cancelled" }, + }); + expect(parseRequestPermissionResult({ outcome: { outcome: "selected", optionId: "allow_once" } })).toEqual({ + outcome: { outcome: "selected", optionId: "allow_once" }, + }); + expect(parseRequestPermissionResult({ outcome: {} })).toBeNull(); + // The parser stays a pass-through: it does not know which option ids the + // mode offers, so a whitelist here would silently drop a new one. + expect(parseRequestPermissionResult({ outcome: { outcome: "selected", optionId: "allow_always" } })).toEqual({ + outcome: { outcome: "selected", optionId: "allow_always" }, + }); + expect(parseRequestPermissionResult({ outcome: { outcome: "selected", optionId: "reject_always" } })).toEqual({ + outcome: { outcome: "selected", optionId: "reject_always" }, + }); + }); +}); + +describe("acp mcp server conversion", () => { + test("keeps stdio servers with their env and args", () => { + const { declarations, rejected } = convertAcpMcpServers([ + { + name: "filesystem", + command: "/usr/bin/mcp-server", + args: ["--root", "/repo"], + env: [ + { name: "TOKEN", value: "secret" }, + { name: "MODE", value: "rw" }, + ], + }, + ]); + expect(declarations).toEqual({ + filesystem: { + command: "/usr/bin/mcp-server", + args: ["--root", "/repo"], + env: { TOKEN: "secret", MODE: "rw" }, + }, + }); + expect(rejected).toEqual([]); + }); + + test("keeps http and sse servers with headers and drops only the unstable transport", () => { + const { declarations, rejected } = convertAcpMcpServers([ + { name: "remote", type: "http", url: "https://mcp.example.com/mcp", headers: [{ name: "X-Key", value: "k" }] }, + { name: "legacy", type: "sse", url: "https://mcp.example.com/sse" }, + { name: "unstable", type: "acp" }, + ]); + expect(declarations).toEqual({ + remote: { url: "https://mcp.example.com/mcp", http_headers: { "X-Key": "k" } }, + legacy: { transport: "sse", url: "https://mcp.example.com/sse" }, + }); + expect(rejected).toEqual(["unstable"]); + }); + + test("marks an sse server with its transport and keeps its headers", () => { + const { declarations, rejected } = convertAcpMcpServers([ + { name: "legacy", type: "sse", url: "https://mcp.example.com/sse", headers: [{ name: "X-Key", value: "k" }] }, + ]); + expect(rejected).toEqual([]); + // The transport has to travel with the declaration: the connector picks + // the client class from it, and an SSE URL is indistinguishable from a + // streamable-HTTP one. + expect(declarations).toEqual({ + legacy: { transport: "sse", url: "https://mcp.example.com/sse", http_headers: { "X-Key": "k" } }, + }); + }); + + test("rejects a url server that never named its transport", () => { + const { declarations, rejected } = convertAcpMcpServers([{ name: "remote", url: "https://mcp.example.com/mcp" }]); + // ACP v1 discriminates the union on `type`, so a `url` without one is not + // a stdio server either. Reporting it beats spawning a bogus command. + expect(declarations).toEqual({}); + expect(rejected).toEqual(["remote"]); + }); + + test("reports an sse server that names no url instead of dropping it", () => { + const { declarations, rejected } = convertAcpMcpServers([ + { name: "legacy", type: "sse" }, + { name: "empty-legacy", type: "sse", url: "" }, + ]); + expect(declarations).toEqual({}); + expect(rejected).toEqual(["legacy", "empty-legacy"]); + }); + + test("reports entries that cannot start instead of dropping them silently", () => { + const { declarations, rejected } = convertAcpMcpServers([ + { name: "no-command" }, + { name: "http-without-url", type: "http" }, + { command: "/usr/bin/mcp" }, + "not-an-object", + ]); + expect(declarations).toEqual({}); + expect(rejected).toEqual(["no-command", "http-without-url", "", ""]); + }); + + test("treats a missing or non-array list as no servers", () => { + expect(convertAcpMcpServers(undefined)).toEqual({ declarations: {}, rejected: [] }); + expect(convertAcpMcpServers([])).toEqual({ declarations: {}, rejected: [] }); + expect(convertAcpMcpServers("nope")).toEqual({ declarations: {}, rejected: [] }); + }); + + test("rejects a non-array mcpServers field in session requests", () => { + expect(validateNewSessionParams({ cwd: "/repo", mcpServers: {} }).ok).toBe(false); + expect(validateLoadSessionParams({ sessionId: "s1", cwd: "/repo", mcpServers: {} }).ok).toBe(false); + expect(validateNewSessionParams({ cwd: "/repo", mcpServers: [{ name: "a", command: "b" }] }).ok).toBe(true); + }); +}); + +describe("acp prompt normalization with embedded resources", () => { + test("inlines text resources and notes binary ones", () => { + const normalized = normalizePrompt([ + { type: "text", text: "look at this" }, + { type: "resource", resource: { uri: "file:///repo/a.ts", mimeType: "text/plain", text: "const a = 1;" } }, + { type: "resource", resource: { uri: "file:///repo/b.png", blob: "AAAA" } }, + ]); + expect(normalized.text).toBe( + "look at this\n[[resource file:///repo/a.ts]]\nconst a = 1;\n[[resource file:///repo/b.png]] (base64 payload, 4 chars)", + ); + expect(normalized.images).toEqual([]); + }); + + test("accepts embedded resource blocks and rejects empty ones", () => { + expect( + validatePromptParams({ + sessionId: "s1", + prompt: [{ type: "resource", resource: { uri: "file:///repo/a.ts", text: "body" } }], + }).ok, + ).toBe(true); + expect( + validatePromptParams({ + sessionId: "s1", + prompt: [{ type: "resource", resource: { uri: "file:///repo/a.ts" } }], + }).ok, + ).toBe(false); + }); +}); + +describe("acp elicitation responses", () => { + test("reads an accepted answer with content", () => { + expect(parseElicitationResult({ action: "accept", content: { value: "hello" } })).toEqual({ + action: "accept", + content: { value: "hello" }, + }); + }); + + test("treats decline and cancel as contentless", () => { + expect(parseElicitationResult({ action: "decline" })).toEqual({ action: "decline" }); + expect(parseElicitationResult({ action: "cancel" })).toEqual({ action: "cancel" }); + }); + + test("rejects an unknown action and a non-object accept payload", () => { + expect(parseElicitationResult({ action: "maybe" })).toBeUndefined(); + expect(parseElicitationResult({ action: "accept", content: "text" })).toBeUndefined(); + expect(parseElicitationResult(null)).toBeUndefined(); + }); +}); + +describe("acp mode and config option validation", () => { + test("requires sessionId plus the value being set", () => { + expect(validateSetSessionModeParams({ sessionId: "s1", modeId: "ask" }).ok).toBe(true); + expect(validateSetSessionModeParams({ sessionId: "s1" }).ok).toBe(false); + expect(validateSetSessionModeParams({ modeId: "ask" }).ok).toBe(false); + }); + + test("reads a string config value by default and a boolean when typed", () => { + expect(validateSetSessionConfigOptionParams({ sessionId: "s1", optionId: "model", value: "step/x" })).toEqual({ + ok: true, + value: { sessionId: "s1", optionId: "model", value: "step/x" }, + }); + expect( + validateSetSessionConfigOptionParams({ sessionId: "s1", optionId: "flag", type: "boolean", value: true }).ok, + ).toBe(true); + }); + + test("rejects a mismatched value type and an unknown type", () => { + expect(validateSetSessionConfigOptionParams({ sessionId: "s1", optionId: "model", value: true }).ok).toBe(false); + expect( + validateSetSessionConfigOptionParams({ sessionId: "s1", optionId: "flag", type: "boolean", value: "yes" }).ok, + ).toBe(false); + expect( + validateSetSessionConfigOptionParams({ sessionId: "s1", optionId: "flag", type: "number", value: 1 }).ok, + ).toBe(false); + }); +}); + +describe("acp initialize validation", () => { + test("returns the client capabilities the mode branches on", () => { + const parsed = validateInitializeParams({ + protocolVersion: 1, + clientCapabilities: { elicitation: { form: {} }, auth: { terminal: true } }, + }); + expect(parsed.ok).toBe(true); + expect(parsed.ok && parsed.value.clientCapabilities?.elicitation?.form).toEqual({}); + expect(parsed.ok && parsed.value.clientCapabilities?.auth?.terminal).toBe(true); + }); + + test("rejects a malformed capability bag", () => { + expect(validateInitializeParams({ protocolVersion: 1, clientCapabilities: [] }).ok).toBe(false); + expect(validateInitializeParams({ protocolVersion: 1, clientCapabilities: { fs: 3 } }).ok).toBe(false); + }); +}); + +describe("acp turn error extraction", () => { + /** Minimal shape of the assistant messages a turn leaves behind. */ + const messages = (list: unknown[]) => list as Parameters[0]; + + test("returns the error message of the last assistant message when the turn failed", () => { + expect( + extractTurnError( + messages([ + { role: "user", content: [] }, + { role: "assistant", content: [], stopReason: "error", errorMessage: "404: gone" }, + ]), + ), + ).toBe("404: gone"); + }); + + test("returns undefined for a turn that ended normally", () => { + expect( + extractTurnError( + messages([ + { role: "user", content: [] }, + { role: "assistant", content: [{ type: "text", text: "hi" }], stopReason: "stop" }, + ]), + ), + ).toBeUndefined(); + }); + + test("does not replay an older failed turn onto a later successful one", () => { + expect( + extractTurnError( + messages([ + { role: "assistant", content: [], stopReason: "error", errorMessage: "boom" }, + { role: "user", content: [] }, + { role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop" }, + ]), + ), + ).toBeUndefined(); + }); + + test("falls back to a generic message when the engine recorded no detail", () => { + expect(extractTurnError(messages([{ role: "assistant", content: [], stopReason: "error" }]))).toBe( + "The turn ended with an error.", + ); + }); + + test("returns undefined when no assistant message exists yet", () => { + expect(extractTurnError(messages([{ role: "user", content: [] }]))).toBeUndefined(); + expect(extractTurnError(messages([]))).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 0b322a11..7ad9e710 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -144,6 +144,18 @@ describe("parseArgs", () => { expect(result.mode).toBe("rpc"); }); + test("parses --mode acp", () => { + const result = parseArgs(["--mode", "acp"]); + expect(result.mode).toBe("acp"); + }); + + test("rejects completion checks in acp mode", () => { + const result = parseArgs(["--mode", "acp", "--completion-check", "git-committed"]); + expect(result.diagnostics).toEqual([ + { type: "error", message: "--completion-check is only supported in print or JSON mode" }, + ]); + }); + test("parses --session", () => { const result = parseArgs(["--session", "/path/to/session.jsonl"]); expect(result.session).toBe("/path/to/session.jsonl"); diff --git a/packages/coding-agent/test/client-fs.test.ts b/packages/coding-agent/test/client-fs.test.ts new file mode 100644 index 00000000..281775a5 --- /dev/null +++ b/packages/coding-agent/test/client-fs.test.ts @@ -0,0 +1,147 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { + getClientFileSystemBridge, + setClientFileSystemBridge, + withClientFileSystemEdit, + withClientFileSystemEditOptions, + withClientFileSystemRead, + withClientFileSystemReadOptions, + withClientFileSystemWrite, + withClientFileSystemWriteOptions, +} from "../src/core/tools/client-fs.ts"; +import { defaultEditOperations } from "../src/core/tools/edit.ts"; +import { defaultReadOperations } from "../src/core/tools/read.ts"; +import { defaultWriteOperations } from "../src/core/tools/write.ts"; + +const workspaces: string[] = []; + +afterEach(async () => { + setClientFileSystemBridge(undefined); + await Promise.all(workspaces.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function workspace(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "client-fs-")); + workspaces.push(dir); + return dir; +} + +test("a bridge read replaces the local read and a cleared one restores it", async () => { + const dir = await workspace(); + const file = path.join(dir, "note.txt"); + await writeFile(file, "on disk", "utf-8"); + const readTextFile = vi.fn(async () => "in the editor buffer"); + + setClientFileSystemBridge({ readTextFile }); + const bridged = withClientFileSystemRead(defaultReadOperations); + + const buffer = await bridged.readFile(file); + expect(buffer.toString("utf-8")).toBe("in the editor buffer"); + expect(readTextFile).toHaveBeenCalledWith(file); + + setClientFileSystemBridge(undefined); + expect((await bridged.readFile(file)).toString("utf-8")).toBe("on disk"); +}); + +test("a client that advertises only writes leaves reads local", async () => { + const dir = await workspace(); + const file = path.join(dir, "note.txt"); + await writeFile(file, "on disk", "utf-8"); + const writeTextFile = vi.fn(async () => {}); + + setClientFileSystemBridge({ writeTextFile }); + const bridged = withClientFileSystemRead(defaultReadOperations); + + // The bridge exists, so a wrapper that checked only for a bridge would route + // the read and get nothing back. + expect((await bridged.readFile(file)).toString("utf-8")).toBe("on disk"); + expect(getClientFileSystemBridge()?.readTextFile).toBeUndefined(); +}); + +test("an image read stays local even when a bridge is installed", async () => { + const dir = await workspace(); + // A PNG magic number, so the local path sees a real image rather than text. + const file = path.join(dir, "shot.png"); + await writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + const readTextFile = vi.fn(async () => "not bytes"); + + setClientFileSystemBridge({ readTextFile }); + const bridged = withClientFileSystemRead(defaultReadOperations); + + const buffer = await bridged.readFile(file); + expect(buffer.subarray(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47])); + expect(readTextFile).not.toHaveBeenCalled(); +}); + +test("a failed client read falls back to the local file", async () => { + const dir = await workspace(); + const file = path.join(dir, "note.txt"); + await writeFile(file, "on disk", "utf-8"); + setClientFileSystemBridge({ + readTextFile: async () => { + throw new Error("client said methodNotFound"); + }, + }); + const bridged = withClientFileSystemRead(defaultReadOperations); + + await expect(bridged.readFile(file)).rejects.toThrow("methodNotFound"); +}); + +test("edit reads and writes travel the same road", async () => { + const dir = await workspace(); + const file = path.join(dir, "note.txt"); + await writeFile(file, "old", "utf-8"); + const readTextFile = vi.fn(async () => "old"); + const writeTextFile = vi.fn(async () => {}); + + setClientFileSystemBridge({ readTextFile, writeTextFile }); + const bridged = withClientFileSystemEdit(defaultEditOperations); + + expect((await bridged.readFile(file)).toString("utf-8")).toBe("old"); + await bridged.writeFile(file, "new"); + expect(writeTextFile).toHaveBeenCalledWith(file, "new"); +}); + +test("a write with no client bridge writes locally", async () => { + const dir = await workspace(); + const file = path.join(dir, "note.txt"); + const bridged = withClientFileSystemWrite(defaultWriteOperations); + + await bridged.writeFile(file, "written"); + if (bridged.readFile === undefined) throw new Error("readFile missing"); + const readBack = await bridged.readFile(file); + expect(readBack?.toString("utf-8")).toBe("written"); +}); + +test("option wrappers bridge a caller that passed no operations", async () => { + const dir = await workspace(); + const file = path.join(dir, "note.txt"); + await writeFile(file, "on disk", "utf-8"); + const readTextFile = vi.fn(async () => "through the wrapper"); + + setClientFileSystemBridge({ readTextFile }); + // No operations supplied, which is the common case: wrapping only a supplied + // object would leave this path permanently local. + const options = withClientFileSystemReadOptions(undefined); + const operations = options.operations; + expect(operations).toBeDefined(); + if (operations === undefined) throw new Error("operations missing"); + expect((await operations.readFile(file)).toString("utf-8")).toBe("through the wrapper"); + expect(withClientFileSystemEditOptions(undefined).operations).toBeDefined(); + expect(withClientFileSystemWriteOptions(undefined).operations).toBeDefined(); +}); + +test("the bridge is process-scoped and cleared between hosts", () => { + expect(getClientFileSystemBridge()).toBeUndefined(); + setClientFileSystemBridge({ readTextFile: async () => "one" }); + expect(getClientFileSystemBridge()?.readTextFile).toBeDefined(); + setClientFileSystemBridge(undefined); + expect(getClientFileSystemBridge()).toBeUndefined(); +}); + +beforeEach(() => { + vi.restoreAllMocks(); +}); diff --git a/packages/coding-agent/test/client-terminal.test.ts b/packages/coding-agent/test/client-terminal.test.ts new file mode 100644 index 00000000..1bd35f9c --- /dev/null +++ b/packages/coding-agent/test/client-terminal.test.ts @@ -0,0 +1,216 @@ +import { afterEach, expect, test, vi } from "vitest"; +import type { BashOperations } from "../src/core/tools/bash.ts"; +import { + type ClientTerminalHost, + getClientTerminalHost, + setClientTerminalHost, + withClientTerminalOperations, +} from "../src/core/tools/client-terminal.ts"; +import type { ShellConfig } from "../src/utils/shell.ts"; + +afterEach(() => { + setClientTerminalHost(undefined); + vi.restoreAllMocks(); +}); + +const argvShell: ShellConfig = { shell: "/bin/sh", args: ["-c"] }; + +function recordingFallback(): BashOperations & { calls: Array<{ command: string; cwd: string }> } { + const calls: Array<{ command: string; cwd: string }> = []; + return { + calls, + exec: async (command, cwd) => { + calls.push({ command, cwd }); + return { exitCode: 0 }; + }, + }; +} + +function hostThat(overrides: Partial = {}): ClientTerminalHost & { + created: Array>; + released: string[]; +} { + const created: Array> = []; + const released: string[] = []; + return { + created, + released, + create: async (params) => { + created.push(params as unknown as Record); + return { terminalId: "term-1" }; + }, + output: async () => ({ output: "", truncated: false }), + waitForExit: async () => ({ exitCode: 0 }), + kill: async () => {}, + release: async (terminalId) => { + released.push(terminalId); + }, + ...overrides, + }; +} + +test("a command runs through the client terminal with the resolved shell", async () => { + const host = hostThat({ + output: async function output() { + // Exits on the first poll so the test does not spin. + return { output: "done\n", truncated: false, exitStatus: { exitCode: 0 } }; + }, + }); + setClientTerminalHost(host); + const fallback = recordingFallback(); + const operations = withClientTerminalOperations(fallback, () => argvShell); + + const result = await operations.exec("echo hi", "/repo", { onData: () => {} }); + + expect(result.exitCode).toBe(0); + expect(host.created).toHaveLength(1); + // The shell string rides as the final argument, so the client runs the same + // command line the local path would have. + expect(host.created[0]).toMatchObject({ command: "/bin/sh", args: ["-c", "echo hi"], cwd: "/repo" }); + expect(host.released).toEqual(["term-1"]); + expect(fallback.calls).toHaveLength(0); +}); + +test("output is streamed as it appears and only the suffix is emitted", async () => { + const chunks: string[] = []; + let polls = 0; + const host = hostThat({ + output: async () => { + polls += 1; + if (polls === 1) return { output: "first\n", truncated: false }; + if (polls === 2) return { output: "first\nsecond\n", truncated: false }; + return { output: "first\nsecond\n", truncated: false, exitStatus: { exitCode: 0 } }; + }, + }); + setClientTerminalHost(host); + const operations = withClientTerminalOperations(recordingFallback(), () => argvShell); + + await operations.exec("run", "/repo", { onData: (data) => chunks.push(data.toString("utf-8")) }); + + expect(chunks).toEqual(["first\n", "second\n"]); +}); + +test("a truncated client buffer is not re-emitted", async () => { + const chunks: string[] = []; + let polls = 0; + const host = hostThat({ + output: async () => { + polls += 1; + if (polls === 1) return { output: "aaaa", truncated: false }; + // The client dropped the head to stay inside its byte limit. + if (polls === 2) return { output: "bbbb", truncated: true }; + return { output: "bbbb", truncated: true, exitStatus: { exitCode: 0 } }; + }, + }); + setClientTerminalHost(host); + const operations = withClientTerminalOperations(recordingFallback(), () => argvShell); + + await operations.exec("run", "/repo", { onData: (data) => chunks.push(data.toString("utf-8")) }); + + // Re-emitting the whole buffer would duplicate output the accumulator holds. + expect(chunks).toEqual(["aaaa"]); +}); + +test("a stdin-transport shell falls back before the command starts", async () => { + const host = hostThat({ + output: async () => ({ output: "", truncated: false, exitStatus: { exitCode: 0 } }), + }); + setClientTerminalHost(host); + const fallback = recordingFallback(); + // ACP has no channel for a command delivered over stdin, so this shell cannot + // be expressed and the local path keeps it. + const operations = withClientTerminalOperations(fallback, () => ({ + shell: "/bin/sh", + args: ["-s"], + commandTransport: "stdin", + })); + + const result = await operations.exec("echo hi", "/repo", { onData: () => {} }); + + expect(result.exitCode).toBe(0); + expect(host.created).toHaveLength(0); + expect(fallback.calls).toEqual([{ command: "echo hi", cwd: "/repo" }]); +}); + +test("a failed create falls back to the local shell", async () => { + const host = hostThat({ + create: async () => { + throw new Error("client refused"); + }, + }); + setClientTerminalHost(host); + const fallback = recordingFallback(); + const operations = withClientTerminalOperations(fallback, () => argvShell); + + const result = await operations.exec("echo hi", "/repo", { onData: () => {} }); + + expect(result.exitCode).toBe(0); + // Safe because the command never started: there is no second side effect. + expect(fallback.calls).toEqual([{ command: "echo hi", cwd: "/repo" }]); + expect(host.released).toEqual([]); +}); + +test("a failure after create is never retried locally", async () => { + const host = hostThat({ + output: async () => { + throw new Error("transport died mid-run"); + }, + }); + setClientTerminalHost(host); + const fallback = recordingFallback(); + const operations = withClientTerminalOperations(fallback, () => argvShell); + + await expect(operations.exec("rm -rf build", "/repo", { onData: () => {} })).rejects.toThrow("transport died"); + // The command already ran in the client's terminal. Re-running it locally + // would be a second side effect, not a fallback. + expect(fallback.calls).toHaveLength(0); + expect(host.released).toEqual(["term-1"]); +}); + +test("an abort kills the terminal and surfaces as aborted", async () => { + const killed: string[] = []; + const controller = new AbortController(); + const host = hostThat({ + output: async () => ({ output: "", truncated: false }), + kill: async (terminalId) => { + killed.push(terminalId); + controller.abort(); + }, + }); + setClientTerminalHost(host); + const operations = withClientTerminalOperations(recordingFallback(), () => argvShell); + + // Abort from inside the poll loop, so the kill path is the one under test. + setTimeout(() => controller.abort(), 0); + await expect(operations.exec("sleep 100", "/repo", { onData: () => {}, signal: controller.signal })).rejects.toThrow( + "aborted", + ); + expect(killed).toEqual(["term-1"]); + expect(host.released).toEqual(["term-1"]); +}); + +test("a timeout kills the terminal and reports the timeout", async () => { + const killed: string[] = []; + const host = hostThat({ + output: async () => ({ output: "", truncated: false }), + kill: async (terminalId) => { + killed.push(terminalId); + }, + }); + setClientTerminalHost(host); + const operations = withClientTerminalOperations(recordingFallback(), () => argvShell); + + await expect(operations.exec("sleep 100", "/repo", { onData: () => {}, timeout: 0 })).rejects.toThrow("timeout:0"); + expect(killed).toEqual(["term-1"]); +}); + +test("no host means purely local execution", async () => { + expect(getClientTerminalHost()).toBeUndefined(); + const fallback = recordingFallback(); + const operations = withClientTerminalOperations(fallback, () => argvShell); + + const result = await operations.exec("echo hi", "/repo", { onData: () => {} }); + + expect(result.exitCode).toBe(0); + expect(fallback.calls).toEqual([{ command: "echo hi", cwd: "/repo" }]); +}); diff --git a/packages/coding-agent/test/mode-reads-piped-stdin.test.ts b/packages/coding-agent/test/mode-reads-piped-stdin.test.ts new file mode 100644 index 00000000..3c413888 --- /dev/null +++ b/packages/coding-agent/test/mode-reads-piped-stdin.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; +import { modeReadsPipedStdin, readPipedStdin } from "../src/main.ts"; + +describe("modeReadsPipedStdin", () => { + test("RPC and ACP never drain stdin, because stdin is their transport", () => { + // ACP speaks newline-delimited JSON-RPC on stdin. If startup treated that + // pipe as a piped prompt it would swallow the protocol stream, and since + // an ACP client keeps the pipe open for the whole session the read would + // hang on `end` and the mode would never start. + expect(modeReadsPipedStdin("acp")).toBe(false); + expect(modeReadsPipedStdin("rpc")).toBe(false); + }); + + test("every prompt-carrying mode still reads piped stdin", () => { + expect(modeReadsPipedStdin("print")).toBe(true); + expect(modeReadsPipedStdin("json")).toBe(true); + expect(modeReadsPipedStdin("interactive")).toBe(true); + }); +}); + +describe("readPipedStdin", () => { + test("a TTY is never read, so an interactive session does not consume the terminal", async () => { + const original = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + try { + await expect(readPipedStdin()).resolves.toBeUndefined(); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: original, configurable: true }); + } + }); +}); diff --git a/packages/coding-agent/test/step-plan-extension.test.ts b/packages/coding-agent/test/step-plan-extension.test.ts index d584f188..f284c7ff 100644 --- a/packages/coding-agent/test/step-plan-extension.test.ts +++ b/packages/coding-agent/test/step-plan-extension.test.ts @@ -537,7 +537,7 @@ test.each([true, false])("rpc-mode exit_plan_mode hands approval to the caller ( expect(result.content).toEqual([ { type: "text", - text: `Cannot gate approval in rpc child context. Plan file at ${planPath}; caller should approve externally. Plan mode exited and file mutations are re-enabled.\n\nPlan contents:\n${planContents}`, + text: `Cannot gate approval in a headless protocol child context. Plan file at ${planPath}; caller should approve externally. Plan mode exited and file mutations are re-enabled.\n\nPlan contents:\n${planContents}`, }, ]); expect(result.details).toEqual({ @@ -559,7 +559,8 @@ test.each([true, false])("rpc-mode exit_plan_mode hands approval to the caller ( expect( harness.notifications.some( (notification) => - notification.type === "warning" && notification.message.includes("rpc child auto-approved plan mode exit"), + notification.type === "warning" && + notification.message.includes("headless protocol child auto-approved plan mode exit"), ), ).toBe(true); expect(events.at(-1)).toEqual({