Repository navigation
Conversation
li-xiu-qi
force-pushed
the
feat/native-acp-stdio
branch
12 times, most recently
from
October 28, 2026 17:19
5526b56 to
6c35edd
Compare
li-xiu-qi
marked this pull request as ready for review
October 28, 2026 17:23
li-xiu-qi
requested review from
MelodyVAR,
Uking-xxx,
ZouR-Ma and
liuedcson
as code owners
October 28, 2026 17:23
li-xiu-qi
marked this pull request as draft
October 28, 2026 17:35
li-xiu-qi
force-pushed
the
feat/native-acp-stdio
branch
2 times, most recently
from
October 28, 2026 19:31
01ef180 to
74623bd
Compare
li-xiu-qi
marked this pull request as ready for review
October 28, 2026 19:38
li-xiu-qi
marked this pull request as draft
October 29, 2026 08:00
li-xiu-qi
force-pushed
the
feat/native-acp-stdio
branch
2 times, most recently
from
October 29, 2026 08:41
79ebb7c to
4c7be82
Compare
Drive step-code from an ACP client over stdio, which is the only transport the protocol makes mandatory and the one Zed and JetBrains already speak. The mode answers `initialize` with the capabilities it actually implements, so a spec-following client never calls a method that was never advertised. Sessions cover the full lifecycle the schema defines, plus the two extensions clients that talk to agents carrying them will send: `session/set_model` and `session/fork`. Client-side file reads and writes are bridged into the read, write, and edit tools, gated per direction on what the client advertised, because a client read returns the editor's buffer where a local read returns the older on-disk content. Terminals are bridged into the bash tool, so commands run where the user already is. MCP covers all three transports the protocol defines. stdio and Streamable HTTP were already reachable; SSE is wired through its own transport now, selected from the field the client named rather than guessed from the URL, with headers applied to both legs because the stream request and the POSTs are separate. Only the unstable `acp` transport is rejected, since nothing can act on it. Permissions offer `allow_always` and `reject_always` alongside the one-shot answers. Both are session-scoped, so they are cleared when the active session changes: carrying an allow across a switch would approve tool calls in a session whose user never consented to them. A reject outranks an allow when both are somehow set, because a contradictory pair must not read as consent. `additionalDirectories` is validated on every session-entry method and reported on stderr when it cannot be honoured. A stdio session resolves every path against one working directory, so extra roots need path resolution re-plumbed through every tool; saying so beats leaving the client believing it granted roots the agent cannot reach. Tests pin each of those behaviours, and each was checked by breaking the code it guards. The permission cases index requests by position, because an "always" answer stops further requests and a plain search would answer a stale id while appearing to pass.
li-xiu-qi
force-pushed
the
feat/native-acp-stdio
branch
from
October 29, 2026 08:57
4c7be82 to
04db2bd
Compare
li-xiu-qi
marked this pull request as ready for review
October 29, 2026 12:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves #224
Problem
Step had no Agent Client Protocol entry point, so a client that speaks ACP v1 could not drive it at all. This PR adds a native
--mode acpheadless mode that speaks ACP v1 over stdio.Two defects surfaced while building it and are fixed here:
prepareMaindrained piped stdin for every mode exceptrpc. In ACP mode stdin is the JSON-RPC transport, so the pipe that carriedinitializewas consumed as a prompt before the mode started. With a client that keeps the pipe open, which every real ACP client does, the read waited forendthat never came and the mode never started at all.modeReadsPipedStdin()now names the rule and excludes both transport modes.A failed turn answered
session/promptwithend_turn. A failed model call does not reject the engine'sprompt(); it is recorded on the assistant message, and ACP v1 has no error stop reason (StopReasoncoversend_turn,max_tokens,max_turn_requests,refusal,cancelled). The client was therefore told the agent answered when it had not. The engine's message now goes out on the JSON-RPC error channel, and the failed assistant message stays in the session so it can still be resumed.Changes
initialize,session/new,session/load,session/prompt, andsession/cancelare mapped onto the existingAgentSessionRuntimeHost; no new session engine is introduced.session/updatenotifications: assistant text, thinking, tool calls, and tool results (a running tool reportsin_progresswith its partial output, and a completed one reports what it produced, bounded so a single call cannot flood the client).session/newandsession/loadare connected for that session and their tools are exposed to the model. stdio, the protocol's mandatory transport, which in v1 carries notypefield, and both remote transports the protocol defines,type: "http"andtype: "sse", are accepted and advertised asmcpCapabilities: { http: true, sse: true }. A client server replaces a same-namedconfig.tomlentry so a stale global entry can be overridden per session; a session that sends none starts with none of the previous client's servers. Unsupported transports and entries that cannot start are reported on stderr instead of being dropped silently.config.tomland plugin manifests, and/mcpplus the session status view remain the way to inspect what is actually connected.sseis wired even though the protocol marks the transport deprecated and ACP v2 removes it, because clients still send it and the engine already has a transport for it. Only an entry that names nourl, or names a transport outside the three the protocol defines, is rejected, and it is reported on stderr rather than dropped silently.elicitation/createbridges extensionselect()andinput()dialogs, so an extension can ask a real question instead of being answered by an allow/reject approval. A client that does not advertiseclientCapabilities.elicitation.formkeeps the previous fallback, which is honest about what the protocol can carry rather than guessing an answer.authenticateadvertises a terminal login method only when the client advertisesclientCapabilities.auth.terminal, and completes an API-key method through elicitation, storing the key wherestep loginstores it.logoutclears it. When a Step model has no credential, session setup fails with the ACPauth_requirederror instead of letting the first request fail.session/set_modemaps onto the Step permission tiers by running the same extension command the TUI runs, so the policy stays in one place and the full-access risk confirmation survives.session/set_config_optionexposes the model selector and, when the active model has one, the reasoning level. Both are reported in the session lifecycle responses and emit an update notification on change.session/list,session/delete,session/close, andsession/resumeround out session management. The protocol splits the two session-entry points by replay, and both directions are honoured:session/loadstreams the stored conversation assession/updatebefore it answers, because the spec makes that a MUST, andsession/resumestreams nothing, because the spec makes that a MUST NOT. An earlier revision of this PR had it backwards, replaying onresumebehind areplayFromswitch the protocol does not define and not replaying onloadat all; a client that loaded a session saw an empty transcript, and one that resumed saw every message twice.session/listpages at a fixed size and returnsnextCursoruntil the store is exhausted, and a cursor it did not issue is rejected withinvalidParamsrather than answered with the first page again, which would replay sessions the client already holds.session/promptaccepts embeddedresourceblocks, andinitializeadvertisesembeddedContext: trueand every optional session method that is implemented. ACP gates those methods behind their capability, so an emptysessionCapabilitiesmade list/close/delete/resume invisible to a client that follows the spec.select()andinput(), login startup exclusion, and completion-check rejection.fs/read_text_fileandfs/write_text_fileare bridged into theread,write, andedittools, gated per direction onclientCapabilities.fs. A client read returns the editor's buffer, so an unsaved change is seen rather than the older on-disk content; a client write lets the client track what the agent changed. Images stay local, because the method is namedread_text_fileand routing bytes through it would lose them.terminal/create,terminal/output,terminal/wait_for_exit,terminal/kill, andterminal/releaseare bridged into thebashtool, gated onclientCapabilities.terminal. ACP takes a program plus arguments where the agent's pipeline is a shell string, so the resolved shell config supplies the program and the string rides as the final argument.session/set_modelandsession/forkare implemented. Both sit outside the v1 schema, which fixes the method set at twelve; they are accepted because clients that talk to agents carrying them will send them, and their responses mirror the in-schema methods they alias.Files
packages/coding-agent/src/modes/acp/acp-types.ts,acp-wire.ts,acp-mode.tsapps/cli/src/modes/acp.tsapps/cli/src/main.ts,packages/coding-agent/src/main.ts,packages/coding-agent/src/cli/args.ts,packages/coding-agent/docs/acp.md, README/usage/security/settings/quickstart/extensions docsBehavior and compatibility
The mode speaks newline-delimited JSON-RPC on stdin/stdout, so the smallest useful client is a pipe. From a built checkout:
Then, one JSON object per line on stdin.
cwdmust be the agent's own working directory, because a stdio session cannot relocate; anything else is rejected withinvalidParams.{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true},"terminal":true}}} {"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"<the directory step was started in>","mcpServers":[]}} {"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"<from session/new>","prompt":[{"type":"text","text":"list the files here"}]}}What comes back, verified against a built CLI:
initializeanswers withprotocolVersion, theagentCapabilitiesblock,agentInfo, andauthMethods. TheclientCapabilitiesabove are what switch on the filesystem and terminal bridges; omitting them leaves both local.session/newanswers withsessionId,modes, andconfigOptions. It needs a configured model and credential first: with neither, it fails withunavailablerather thanauth_required, because the credential gate only fires once a Step model is registered. The error names what to configure, since an ACP client cannot run the interactive setup that would fix it.session/promptanswers only after the turn finishes, withstopReasonset toend_turn,max_tokens, orcancelled; a failed model turn uses the JSON-RPC error channel. While it runs,session/updatenotifications carry the thought and message chunks, and a tool that needs approval raisessession/request_permissionand waits.Diagnostics, including the stderr notices for a rejected MCP transport or a failed client read, go to stderr, so stdout stays a clean JSON-RPC channel.
docs/acp.mdhas the full method table.Coverage against another ACP v1 agent
The closest public reference is Kimi Code, which also implements ACP v1 on the official SDK. Its capability matrix and method coverage is the baseline below, read from that repository's
mainon 2026-10-27. Both agents are v1, so the difference is coverage, not protocol generation; the linked document is a moving target, so the date is what makes the table checkable.protocolVersion = 1, fixed fallbackprotocolVersion = 1, negotiated through the SDK@agentclientprotocol/sdk@^1.3.0session/forkandsession/set_modelextensionssession/update,session/request_permission,elicitation/create,fs/read_text_file,fs/write_text_file, and the fiveterminal/*methodselicitation/completeis unimplemented on both sides.session/newandsession/loadmcpServersare forwarded. stdio (absenttype),http, andsseare all forwarded, andmcpCapabilities.httpand.sseare bothtrue.true.image: true,audio: false,embeddedContext: trueimage: true,audio: false,embeddedContext: trueauthenticateoffers a terminal login only when the client advertisesclientCapabilities.auth.terminal, completes an API-key method through elicitation, andlogoutclears it. A Step model with no credential fails session setup withauth_required.authenticatewithmethod_id='login',logout,authMethods(terminal plus a legacy_meta['terminal-auth']fallback), andauthRequired (-32000).The two method surfaces now match, and so does MCP transport coverage. What differs is behavior on two points, and on both this PR is ahead:
typefield in ACP v1, so an absenttypeis read as stdio. Kimi Code rejects that shape and the agent crashes (MoonshotAI/kimi-code#3069).stopReason: "end_turn".sseunimplemented and advertised itfalse; it is wired now, which removes the one gap this table used to carry.Validation
74623bdpasses:build-check-testcompleted the full Linux build, repository checks, and test suite; commit attribution also passes.pnpm run checkpasses: Biome, dependency and boundary checks,tsgo --noEmit, and browser smoke.pnpm --filter @step-harness/coding-agent run build:unbundledandpnpm --filter @step-harness/cli run buildpass on the final rebased head.packages/coding-agentrun during review completed 361 files / 5,185 tests: 315 files and 4,840 tests passed; 39 files and 79 tests failed, seven files and 266 tests skipped. None of the failing files were ACP, plan-mode, or the new SSE suite. The remaining failures are in existing Windows-sensitive process, path, stdout, filesystem-watch, and timing tests; Linux CI remains the clean full-suite gate.Limitations
The filesystem and terminal surfaces fail in opposite directions, and the difference is deliberate rather than incidental. A failed client read falls back to the local file and says so on stderr: a client that advertises
fs.readTextFileand then answersmethodNotFoundshould not turn every read into a hard failure for a file sitting right there on the shared filesystem. A failed client write propagates instead, because writing the same bytes once where the client can see it and once where it cannot is worse than one visible failure.Terminal execution falls back to the local shell only before the command starts, in two cases: no host installed, which is every non-ACP run and every client that did not advertise
terminal; or a resolved shell that feeds the command over stdin, which ACP has no channel to express. Onceterminal/createhas 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 unimplemented:
elicitation/complete, which reports which option a form field resolved to and can only be answered by a client that renders forms.additionalDirectoriesis accepted and validated but does not expand the session's filesystem scope yet.plan,available_commands_update,usage_update, andsession_info_updatenotification variants are not sent, because the session event stream has no equivalent events to map them from. Adding them needs an engine-side change rather than a mapping change, so they are left for a follow-up rather than synthesized from data the engine does not emit.ACP v2 support is deliberately kept out of this PR. ACP v2 is still labeled draft upstream (the SDK ships it behind
@agentclientprotocol/sdk/experimental/v2, and the protocol docs live under/protocol/v2/draft), and the upstream migration guide asks implementers to keep v1 working while adding v2 behind feature flags. The v2 work lives on a separate branch and will be proposed once the protocol stabilizes.src/step/mcp-startup.test.tshas a pre-existing Windows path assertion failure. On the final rebased head, this file produced 32 passes, one skip, and one failure; the five host-overlay tests added by this PR all pass. The failure expects the POSIX fixture path/mock-plugins/localbut receivesC:\mock-plugins\localon Windows. A clean worktree at upstream main5b23dd0reproduces the same failure, so this PR does not alter that unrelated test or production path handling.test/completion-check-cli.test.tsis flaky on Windows, and this PR does not cause it. Every case spawns a freshnode --import tsxprocess that has to compile the whole CLI source graph before it answers, and each spawn carries a fixedtimeout: 20_000. Measured on this machine with nothing else running, the file takes about 406 seconds for 44 cases, roughly 9 seconds per spawn, so the ceiling is only about twice the mean and one case failed on that run. It passes on this branch's Linux CI, which is the only platform that CI runs, so this is a local-Windows cost rather than a red build. It is left alone here on purpose: the fix is a separate concern, and touching it would put unrelated churn into a protocol PR. The same file also failed onorigin/mainwithout these changes.