Skip to content

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

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

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

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 acp headless mode that speaks ACP v1 over stdio.

Two defects surfaced while building it and are fixed here:

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.

Changes

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, native plan review through client select() and input(), 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.

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

Behavior and compatibility

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, max_tokens, or cancelled; a failed model turn uses the JSON-RPC error channel. 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.

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.

Validation

  • GitHub CI on final head 74623bd passes: build-check-test completed the full Linux build, repository checks, and test suite; commit attribution also passes.
  • pnpm run check passes: Biome, dependency and boundary checks, tsgo --noEmit, and browser smoke.
  • pnpm --filter @step-harness/coding-agent run build:unbundled and pnpm --filter @step-harness/cli run build pass on the final rebased head.
  • Targeted ACP protocol and integration tests pass on the final rebased head: 9 files, 398 tests. This covers ACP wire/mode, arguments, stdin routing, client filesystem and terminals, plan-mode approval, and real SSE MCP transport.
  • Official ACP v1 schema validation passes for nine captured runtime messages: initialize, new session, config update, prompt response, and session notifications.
  • Independent code review before the final rebase found no blocking, major, or minor findings. The rebase range-diff was also reviewed: conflict resolution only preserved upstream's live-event options and adapted the plan-mode test to upstream's request-gated helper; ACP behavior was not rewritten.
  • A full Windows packages/coding-agent run 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.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, 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. 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 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.ts has 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/local but receives C:\mock-plugins\local on Windows. A clean worktree at upstream main 5b23dd0 reproduces the same failure, so this PR does not alter that unrelated test or production path handling.

  • test/completion-check-cli.test.ts is flaky on Windows, and this PR does not cause it. Every case spawns a fresh node --import tsx process that has to compile the whole CLI source graph before it answers, and each spawn carries a fixed timeout: 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 on origin/main without these changes.

@li-xiu-qi
li-xiu-qi force-pushed the feat/native-acp-stdio branch 12 times, most recently from 5526b56 to 6c35edd Compare October 28, 2026 17:19
@li-xiu-qi
li-xiu-qi marked this pull request as ready for review October 28, 2026 17:23
@li-xiu-qi
li-xiu-qi marked this pull request as draft October 28, 2026 17:35
@li-xiu-qi
li-xiu-qi force-pushed the feat/native-acp-stdio branch 2 times, most recently from 01ef180 to 74623bd Compare October 28, 2026 19:31
@li-xiu-qi
li-xiu-qi marked this pull request as ready for review October 28, 2026 19:38
@li-xiu-qi
li-xiu-qi marked this pull request as draft October 29, 2026 08:00
@li-xiu-qi
li-xiu-qi force-pushed the feat/native-acp-stdio branch 2 times, most recently from 79ebb7c to 4c7be82 Compare October 29, 2026 08:41
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 4c7be82 to 04db2bd Compare October 29, 2026 08:57
@li-xiu-qi
li-xiu-qi marked this pull request as ready for review October 29, 2026 12:10
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