Skip to content

feat(coding-agent): add native ACP stdio mode - #310

Draft
li-xiu-qi wants to merge 1 commit into
stepfun-ai:mainfrom
li-xiu-qi:feat/native-acp-stdio
Draft

li-xiu-qi wants to merge 1 commit into
stepfun-ai:mainfrom
li-xiu-qi:feat/native-acp-stdio

Conversation

@li-xiu-qi

@li-xiu-qi li-xiu-qi commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Resolves #224

What this adds

A native --mode acp headless mode that speaks Agent Client Protocol v1 over stdio.

  • initialize, session/new, session/load, session/prompt, and session/cancel are mapped onto the existing AgentSessionRuntimeHost; no new session engine is introduced.
  • Agent events stream back as session/update notifications: assistant text, thinking, tool calls, and tool results (a running tool reports in_progress with its partial output, and a completed one reports what it produced, bounded so a single call cannot flood the client).
  • Client MCP servers from session/new and session/load are connected for that session and their tools are exposed to the model. stdio, the protocol's mandatory transport, which in v1 carries no type field, and both remote transports the protocol defines, type: "http" and type: "sse", are accepted and advertised as mcpCapabilities: { http: true, sse: true }. A client server replaces a same-named config.toml entry 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.
    • Scope: local debugging. This is 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.
    • sse is 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 no url, or names a transport outside the three the protocol defines, is rejected, and it is reported on stderr rather than dropped silently.
  • elicitation/create bridges extension select() and input() dialogs, so an extension can ask a real question instead of being answered by an allow/reject approval. A client that does not advertise clientCapabilities.elicitation.form keeps the previous fallback, which is honest about what the protocol can carry rather than guessing an answer.
  • authenticate advertises a terminal login method only when the client advertises clientCapabilities.auth.terminal, and completes an API-key method through elicitation, storing the key where step login stores it. logout clears it. When a Step model has no credential, session setup fails with the ACP auth_required error instead of letting the first request fail.
  • session/set_mode maps 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_option exposes 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, and session/resume round out session management. The protocol splits the two session-entry points by replay, and both directions are honoured: session/load streams the stored conversation as session/update before it answers, because the spec makes that a MUST, and session/resume streams nothing, because the spec makes that a MUST NOT. An earlier revision of this PR had it backwards, replaying on resume behind a replayFrom switch the protocol does not define and not replaying on load at all; a client that loaded a session saw an empty transcript, and one that resumed saw every message twice. session/list pages at a fixed size and returns nextCursor until the store is exhausted, and a cursor it did not issue is rejected with invalidParams rather than answered with the first page again, which would replay sessions the client already holds.
  • session/prompt accepts embedded resource blocks, and initialize advertises embeddedContext: true and every optional session method that is implemented. ACP gates those methods behind their capability, so an empty sessionCapabilities made list/close/delete/resume invisible to a client that follows the spec.
  • Mode-specific bindings are extended for ACP: project trust fallback, extension mode, plan-mode external approval, login startup exclusion, and completion-check rejection.
  • fs/read_text_file and fs/write_text_file are bridged into the read, write, and edit tools, gated per direction on clientCapabilities.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 named read_text_file and routing bytes through it would lose them.
  • terminal/create, terminal/output, terminal/wait_for_exit, terminal/kill, and terminal/release are bridged into the bash tool, gated on clientCapabilities.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_model and session/fork are 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

prepareMain drained piped stdin for every mode except rpc. In ACP mode stdin is the JSON-RPC transport, so the pipe that carried initialize was 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 for end that never came and the mode never started at all. modeReadsPipedStdin() now names the rule and excludes both transport modes.

A failed turn answered session/prompt with 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 (StopReason covers end_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

  • New protocol implementation: packages/coding-agent/src/modes/acp/acp-types.ts, acp-wire.ts, acp-mode.ts
  • New CLI entry: apps/cli/src/modes/acp.ts
  • Mode wiring and docs: apps/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 docs

Trying it

The mode speaks newline-delimited JSON-RPC on stdin/stdout, so the smallest useful client is a pipe. From a built checkout:

step --mode acp

Then, one JSON object per line on stdin. cwd must be the agent's own working directory, because a stdio session cannot relocate; anything else is rejected with invalidParams.

{"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:

  • initialize answers with protocolVersion, the agentCapabilities block, agentInfo, and authMethods. The clientCapabilities above are what switch on the filesystem and terminal bridges; omitting them leaves both local.
  • session/new answers with sessionId, modes, and configOptions. It needs a configured model and credential first: with neither, it fails with unavailable rather than auth_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/prompt answers only after the turn finishes, with stopReason set to end_turn or cancelled. While it runs, session/update notifications carry the thought and message chunks, and a tool that needs approval raises session/request_permission and 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.md has the full method table.

Verification

  • pnpm run check passes: Biome, pinned deps, TS import/layer/ui/workspace/entry/contracts/boundary checks, tsgo --noEmit, and browser smoke.
  • Targeted tests pass: 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 drives initialize, 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, so session/load, session/resume, session/list, session/delete, session/close, and logout are exercised for the found, not-found, wrong-cwd, and refused cases; the replay split is asserted chunk by chunk in both directions, so load emits the stored user, thinking, and assistant chunks before its response and resume emits none even when a client sends the replayFrom switch 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.
  • Two defects the lifecycle tests caught, both fixed here. session/delete answered 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. And session/list accepted a cursor it then ignored while ListSessionsResult already declared nextCursor, 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.
  • Two more defects found while reviewing the rest of the file, both fixed here. validateForkSessionParams was the only session-lifecycle validator that did not require an absolute cwd, so session/fork accepted 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. And session/load did not replay while session/resume did, 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.
  • The mode test is now isolated from developer credentials and from the developer's session store, which is what turned this PR's CI red before it was green. The lifecycle handlers resolve an id against every stored session, so a real agent directory holding hundreds of sessions decided both how long those tests took and whether a not-found case was still a not-found case; the suite now points STEP_CODING_AGENT_DIR at an empty directory. The credential half of that isolation: the ACP auth gate reads both the credential file and STEP_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/new failed with auth_required and took session/new reports the modes and selectors the session can change down 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.
  • Driven against a real stdio client, not only fakes: initialize and session/new return the full capability and mode/config-option sets; a prompt turn streams agent_thought_chunk and agent_message_chunk; a write_file turn emits tool_call, sends session/request_permission, and after the client selects allow_once emits tool_call_update with status: "completed" and writes the file; session/set_mode and session/set_config_option each emit their update notification; a turn whose model call 404s answers with a JSON-RPC error carrying the engine's message instead of end_turn.
  • The repository-wide suite was run on Windows on this branch: packages/coding-agent reports 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 on origin/main (81e79bc) in this working tree, and the failures are pre-existing rather than introduced here. They fall into four groups: spawning a child node process under 360-file parallel load, which times out or is killed with EPERM while cleaning its temp directory; POSIX path assertions that a Windows resolver turns into C:\...; stdout backpressure and fs.watch under load; and one order-dependent file. None of them are in the ACP files, and test/acp-mode.test.ts passes inside the full-suite run on this branch, which is the part the agent-directory isolation above was for.
  • test/step-subagent-events.test.ts is the one file worth naming, because CI flagged it. It fails on Windows on origin/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 is an oversized final output is saved to disk and the notification keeps its summary (a Windows path that XML-escapes differently), on another it is idle 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 drives createStepSubagentExtension directly and never calls createStepToolProfile, so the tool-profile and client-bridge changes in this PR cannot reach it; the ExtensionMode and InputSource changes are type-only and erased at runtime, and the plan-mode-tools.ts change 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 main on 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.

Dimension This PR Kimi Code
Protocol version protocolVersion = 1, fixed fallback protocolVersion = 1, negotiated through the SDK
SDK Hand-written newline-delimited JSON-RPC, fields aligned to the official shapes @agentclientprotocol/sdk@^1.3.0
Session methods The full in-schema set, plus the session/fork and session/set_model extensions The same twelve
Client reverse-RPC session/update, session/request_permission, elicitation/create, fs/read_text_file, fs/write_text_file, and the five terminal/* methods The same eleven. elicitation/complete is unimplemented on both sides.
MCP session/new and session/load mcpServers are forwarded. stdio (absent type), http, and sse are all forwarded, and mcpCapabilities.http and .sse are both true. The same three, with the same two capabilities true.
Embedded context image: true, audio: false, embeddedContext: true image: true, audio: false, embeddedContext: true
Authentication authenticate offers a terminal login only when the client advertises clientCapabilities.auth.terminal, completes an API-key method through elicitation, and logout clears it. A Step model with no credential fails session setup with auth_required. authenticate with method_id='login', logout, authMethods (terminal plus a legacy _meta['terminal-auth'] fallback), and authRequired (-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:

  • A stdio MCP config carries no type field in ACP v1, so an absent type is read as stdio. Kimi Code rejects that shape and the agent crashes (MoonshotAI/kimi-code#3069).
  • A failed turn answers with a JSON-RPC error carrying the engine's message instead of stopReason: "end_turn".
  • MCP transport coverage now matches. An earlier revision of this PR left sse unimplemented and advertised it false; 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.readTextFile and then answers methodNotFound should 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. 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 unimplemented: elicitation/complete, which reports which option a form field resolved to and can only be answered by a client that renders forms. additionalDirectories is accepted and validated but does not expand the session's filesystem scope yet. The plan, available_commands_update, usage_update, and session_info_update notification 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.

@li-xiu-qi
li-xiu-qi force-pushed the feat/native-acp-stdio branch 9 times, most recently from e94df30 to 670957d Compare October 27, 2026 14:58
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
li-xiu-qi force-pushed the feat/native-acp-stdio branch from 670957d to 6228dc7 Compare October 27, 2026 16:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 希望增加原生 ACP 模式(如 step acp)以便编辑器/客户端集成

1 participant