Repository navigation
Conversation
li-xiu-qi
force-pushed
the
feat/native-acp-stdio
branch
9 times, most recently
from
October 27, 2026 14:58
e94df30 to
670957d
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 27, 2026 16:05
670957d to
6228dc7
Compare
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
What this adds
A native
--mode acpheadless mode that speaks Agent Client Protocol v1 over stdio.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.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.Two defects fixed along the way
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.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 docsTrying it
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_turnorcancelled. 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.Verification
pnpm run checkpasses: Biome, pinned deps, TS import/layer/ui/workspace/entry/contracts/boundary checks,tsgo --noEmit, and browser smoke.vitest --run test/acp-wire.test.ts test/acp-mode.test.ts test/args.test.ts test/mode-reads-piped-stdin.test.ts test/read-piped-stdin.test.ts test/client-fs.test.ts test/client-terminal.test.ts test/acp-client-terminal.test.ts(272 tests). The mode test drivesinitialize, a streaming prompt turn, a permission request/response, an elicitation round-trip, mode and config-option switching, cancellation, a failed turn, and a recovery turn after a failure, all without blocking stdin. Every session-lifecycle handler is covered against a real session file on disk rather than a stubbed manager, sosession/load,session/resume,session/list,session/delete,session/close, andlogoutare exercised for the found, not-found, wrong-cwd, and refused cases; the replay split is asserted chunk by chunk in both directions, soloademits the stored user, thinking, and assistant chunks before its response andresumeemits none even when a client sends thereplayFromswitch this PR used to accept. Both replay tests were verified to fail against the previous behaviour. The two client-bridge suites cover the capability gating, the image exception, the read-fallback and write-propagate asymmetry, the terminal output diff, and the two cases where execution stays local.session/deleteanswered twice when the file could not be removed:unlink().catch()logged the failure and fell through to the success reply, so a client holding a locked session file, the ordinary case on Windows, received an error and a result for one request. Andsession/listaccepted acursorit then ignored whileListSessionsResultalready declarednextCursor, so a paging client was handed the first page a second time. Both have regression tests that were verified to fail against the previous behaviour.validateForkSessionParamswas the only session-lifecycle validator that did not require an absolutecwd, sosession/forkaccepted a relative one; it writes the new session under that path and matches it against the source session's stored cwd, so the failure lands somewhere the client never named. Andsession/loaddid not replay whilesession/resumedid, which is the protocol's rule inverted (see the Changes section).src/step/mcp-startup.test.ts: the five overlay tests added here pass (discovery alongside config servers, replacement of a same-named config entry, coexistence with namespaced plugin servers, clearing, and transport-less entries). One unrelated test in that file,a plugin stdio server runs from the plugin root unless it names an absolute cwd, fails on Windows because it asserts POSIX paths against a resolved Windows path; it predates this branch.STEP_CODING_AGENT_DIRat an empty directory. The credential half of that isolation: the ACP auth gate reads both the credential file andSTEP_API_KEY, and the suite pinned neither, so the credential state of whatever machine ran the tests decided whether a session was allowed to start. On a runner with no credential,session/newfailed withauth_requiredand tooksession/new reports the modes and selectors the session can changedown with it; on a machine that happens to carry one, the same test passed for the wrong reason. The file source now points at a missing path and the key comes from the environment, so the tests that exercise the gate override both explicitly.initializeandsession/newreturn the full capability and mode/config-option sets; a prompt turn streamsagent_thought_chunkandagent_message_chunk; awrite_fileturn emitstool_call, sendssession/request_permission, and after the client selectsallow_onceemitstool_call_updatewithstatus: "completed"and writes the file;session/set_modeandsession/set_config_optioneach emit their update notification; a turn whose model call 404s answers with a JSON-RPC error carrying the engine's message instead ofend_turn.packages/coding-agentreports 360 test files, 33 failed, 315 passed, 7 skipped, and 5161 tests, 67 failed, 4828 passed, 266 skipped. Every failure was checked against the same suite onorigin/main(81e79bc) in this working tree, and the failures are pre-existing rather than introduced here. They fall into four groups: spawning a childnodeprocess under 360-file parallel load, which times out or is killed withEPERMwhile cleaning its temp directory; POSIX path assertions that a Windows resolver turns intoC:\...; stdout backpressure andfs.watchunder load; and one order-dependent file. None of them are in the ACP files, andtest/acp-mode.test.tspasses inside the full-suite run on this branch, which is the part the agent-directory isolation above was for.test/step-subagent-events.test.tsis the one file worth naming, because CI flagged it. It fails on Windows onorigin/main(81e79bc) exactly as it does on this branch, and the failing case is not stable between runs: on one run of the same commit it isan oversized final output is saved to disk and the notification keeps its summary(a Windows path that XML-escapes differently), on another it isidle keep-alive children are stopped after the TTL and a reply resumes quietly(the timing race the test's own comment describes as one "a loaded CI runner loses"). Each of those two cases passes when the file is run on its own. The test drivescreateStepSubagentExtensiondirectly and never callscreateStepToolProfile, so the tool-profile and client-bridge changes in this PR cannot reach it; theExtensionModeandInputSourcechanges are type-only and erased at runtime, and theplan-mode-tools.tschange only branches on a mode this test never sets.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.Where the client bridge stops, and why
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: 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. Theplan,available_commands_update,usage_update, andsession_info_updatenotification variants are also 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.Notes
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.