feat(acp): ACP bridge — drive the coding agent from an editor over JSON-RPC stdio

Implements the RFC 010 MVP: a new `@deepseek-ai/dsh-acp` package bridges the
harness agent to the Agent Client Protocol (JSON-RPC 2.0 over newline-delimited
stdio), so Zed and other ACP editors can drive the coding agent — streaming
render, tool-call display, and resumable sessions via `session/load`.

- packages/acp: AgentSideConnection wiring; initialize/newSession/loadSession/
  prompt/cancel; a total TurnEndReason→StopReason codec; settle-once with a
  fallback chain (agent/turn-end → logged turn/end → idle); single-session
  guard; cwd-must-equal-launch-dir validation; load replays from the persisted
  event log (assistant/chunk→agent_message_chunk, tool/call/result→tool_call*).
- agent: add Agent.whenIdle() quiescence signal to the interface; LoopAgent
  implements it (resolves on the first running→idle/disposed transition). The
  bridge awaits it on disposal so teardown reaches quiescence, not just abort.
- examples: extract the shared provider/tool core into examples/base.yml;
  coding-agent nest-includes it; new examples/acp-agent serves the agent over
  ACP with JSONL persistence and no stdout logger (stdout is the protocol).
- Permission gate deferred (TODO(rfc010-permission-gate)): tools run with the
  executor's full authority; only the Agent→sessionId ownership seam is laid
  down. Cancel is best-effort for a not-yet-started queued turn
  (TODO(rfc010-cancel-prestep)). RFC 010 stays `proposed`.
- Docs: package README + Zed snippet; client-driver cookbook section; root and
  packages layout/commands; RFC 010 implementation-status note.

48 bridge tests + whenIdle coverage; 100% per-file coverage; e2e boots the
example as a subprocess and verifies a written file on disk (key-gated, with a
no-key stdout-purity check).
This commit is contained in:
Tianyi Cui
2026-06-16 11:10:25 +08:00
parent add59a3336
commit fb9636db44
39 changed files with 2819 additions and 51 deletions

View File

@@ -0,0 +1,35 @@
# acp-agent example
The DeepSeek Harness coding agent exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio — drive it from Zed or any other ACP client.
```sh
yarn demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env)
```
This boots `@deepseek-ai/dsh-acp` over the shared provider/tool core (`../base.yml`), with `agent-loop` configured with **no pre-created agents** (ACP `session/new` creates them on demand) and JSONL session persistence (so `session/load` works).
## stdout is the protocol
This example loads **no stdout logger**`stdout` carries the JSON-RPC frames, and any other write corrupts them. Do not add `@cordisjs/plugin-logger-console` or a stdio UI here. Use a stderr exporter if you need logs.
## Zed configuration
Add to your Zed `settings.json` under `agent_servers`:
```json
{
"agent_servers": {
"DeepSeek Harness": {
"command": "yarn",
"args": ["demo:acp"],
"env": { "DEEPSEEK_API_KEY": "sk-…" }
}
}
}
```
Run from the repo root (the MVP requires the server's launch directory to be the workspace — see the `cwd` note in `packages/acp`).
## MVP limitations
The bridge is the RFC 010 MVP: single session per connection (RFC 011 lifts this), text-only prompts, `cwd` must equal the launch directory, and the tool-permission gate is deferred (`TODO(rfc010-permission-gate)` — tools run with the executor's full authority). See `packages/acp/README.md` for the full contract.

View File

@@ -0,0 +1,49 @@
# The acp-agent plugin tree, loaded via @cordisjs/plugin-include.
#
# CRITICAL: this example loads NO stdout logger (no @cordisjs/plugin-logger-
# console, no stdio-chat). stdout is reserved for the ACP JSON-RPC protocol —
# anything else written there corrupts the frames (see packages/acp, RFC 010 §
# Risks). Use a stderr exporter if you need logging. The timer plugin is loaded
# (no stdout writes); hmr is omitted (an editor manages the subprocess).
#
# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the
# environment — start.ts loads the gitignored repo-root .env first.
- id: timer
name: '@cordisjs/plugin-timer'
# Shared provider/tool core (llm, sessions, system-prompt, tools, agents,
# invariants, llm-deepseek, bash-local, tool-bash). Nested include resolved
# relative to THIS file's directory.
- id: base
name: '@cordisjs/plugin-include'
config:
path: '../base.yml'
# agent-loop with NO pre-created agents: ACP `session/new` creates them on
# demand (unlike coding-agent, which pre-creates `main`).
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents: []
# Durable session persistence — required by the ACP bridge for `session/load`.
- id: session-persistence
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: './.sessions'
# The ACP bridge: wires AgentSideConnection to stdin/stdout.
- id: acp
name: '@deepseek-ai/dsh-acp'
config:
model: deepseek-v4-flash
systemPrompt: |
You are a coding assistant driven over the Agent Client Protocol.
Your only tools are bash (plus bash_output/bash_kill for background
tasks). Do ALL file operations through bash: read with cat/sed/head,
search with grep, write with heredocs (cat <<'EOF' > file), edit with
sed or a rewrite. Each bash call runs in a fresh shell — pass workdir
instead of cd. Check the [exit code: N] marker; verify your work. Keep
answers brief and factual.

View File

@@ -0,0 +1,7 @@
{
"name": "acp-agent-example",
"description": "Runnable demo: the coding agent as an ACP server over JSON-RPC stdio (Zed & other ACP editors)",
"private": true,
"version": "0.0.1",
"type": "module"
}

View File

@@ -0,0 +1,30 @@
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
// Load DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from a gitignored repo-root .env
// (Node native). Absent file is fine — the environment may already carry them.
//
// IMPORTANT: this server speaks ACP JSON-RPC on stdout. Do NOT add any
// stdout logging here or in cordis.yml — it would corrupt the protocol frames.
// A present-but-unreadable/malformed .env is a real misconfiguration: surface
// it on STDERR (never stdout) rather than silently running with the wrong env.
try {
process.loadEnvFile(new URL('../../.env', import.meta.url).pathname)
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
process.stderr.write(`acp-agent: failed to load .env: ${String(error)}\n`)
}
// ENOENT (no .env) is fine — rely on the ambient environment.
}
const ctx = new Context()
ctx.baseUrl = pathToFileURL(import.meta.dirname).href + '/'
await ctx.plugin(Loader)
await ctx.loader.create({
name: '@cordisjs/plugin-include',
config: {
path: './cordis.yml',
},
})

View File

@@ -0,0 +1,142 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { Readable, Writable } from 'node:stream'
import { mkdtemp, rm, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
type Agent as AcpAgent,
type Client,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
} from '@agentclientprotocol/sdk'
/**
* End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over
* its stdio, drive it with a real ClientSideConnection, send a real prompt, and
* verify the WORLD (a file the agent wrote), not the agent's self-report. Owns
* and disposes the subprocess in afterEach. Key-gated.
*
* Also asserts stdout purity (only framed JSON-RPC on stdout) — that one runs
* WITHOUT a key, since it only needs the server to boot and answer initialize.
*/
const startScript = fileURLToPath(new URL('../start.ts', import.meta.url))
// Resolve tsx's loader to an ABSOLUTE path: the subprocess runs with cwd set to
// a temp workdir (the MVP requires session cwd === process.cwd()), where a bare
// `--import tsx` would not resolve from node_modules. import.meta.resolve gives
// the worktree's tsx regardless of the child's cwd.
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
interface Spawned {
child: ChildProcessWithoutNullStreams
client: ClientSideConnection
updates: SessionNotification['update'][]
stderr: string[]
}
function spawnAcpAgent(cwd: string): Spawned {
const child = spawn(
process.execPath,
['--import', tsxLoader, startScript],
{ cwd, env: { ...process.env }, stdio: ['pipe', 'pipe', 'pipe'] },
)
const stderr: string[] = []
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => stderr.push(chunk))
const updates: SessionNotification['update'][] = []
const stream = ndJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
)
const makeClient = (_agent: AcpAgent): Client => ({
sessionUpdate(params: SessionNotification): Promise<void> {
updates.push(params.update)
return Promise.resolve()
},
requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
// Permission gate is deferred (TODO(rfc010-permission-gate)); the bridge
// never requests permission yet, so just allow if it ever does.
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
},
})
const client = new ClientSideConnection(makeClient, stream)
return { child, client, updates, stderr }
}
let spawned: Spawned | undefined
let workdir: string | undefined
afterEach(async () => {
if (spawned) {
spawned.child.kill('SIGKILL')
spawned = undefined
}
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
workdir = undefined
})
describe('acp-agent stdout purity (no key required)', () => {
it('emits only framed JSON-RPC on stdout', async () => {
workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
// Collect raw stdout bytes directly (bypass the SDK framing) to inspect.
// A dummy key lets the deepseek adapter APPLY (it only checks the key is
// present at boot, not valid — the key is used only on a real model call,
// which this purity test never triggers). So this runs WITHOUT real creds.
const child = spawn(process.execPath, ['--import', tsxLoader, startScript], {
cwd: workdir,
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
stdio: ['pipe', 'pipe', 'pipe'],
})
const out: string[] = []
child.stdout.setEncoding('utf8')
child.stdout.on('data', (c: string) => out.push(c))
// Send a single initialize request as a newline-delimited JSON-RPC frame.
const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } })
child.stdin.write(req + '\n')
// Give it a moment to boot + reply, then inspect stdout.
await new Promise(r => setTimeout(r, 4000))
child.kill('SIGKILL')
const lines = out.join('').split('\n').filter(l => l.trim().length > 0)
expect(lines.length).toBeGreaterThan(0)
for (const line of lines) {
// Every stdout line MUST parse as JSON (a JSON-RPC frame). A non-JSON
// line means a logger/print leaked onto the protocol channel.
expect(() => JSON.parse(line) as unknown).not.toThrow()
}
}, 30_000)
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over ACP', () => {
it('runs a real turn and the agent writes the requested file (verified on disk)', async () => {
workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
spawned = spawnAcpAgent(workdir)
const { client, updates } = spawned
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
// The MVP requires cwd === the server's launch dir (its cwd is `workdir`).
const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
const res = await client.prompt({
sessionId,
prompt: [{ type: 'text', text: 'Use the bash tool to write the exact text ACP_OK into a file named proof.txt in the current directory. Then stop.' }],
})
expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
// Verify the WORLD, not the agent's self-report: read the file from disk.
const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
expect(proof).toContain('ACP_OK')
// And the client saw tool-call activity stream through.
expect(updates.some(u => u.sessionUpdate === 'tool_call')).toBe(true)
}, 180_000)
})