fix(acp): align prompt and workspace contracts

This commit is contained in:
Tianyi Cui
2026-06-17 21:26:31 +08:00
parent 2b36620e55
commit b920239389
12 changed files with 123 additions and 75 deletions

View File

@@ -21,15 +21,15 @@ Add to your Zed `settings.json` under `agent_servers`:
"agent_servers": {
"DeepSeek Harness": {
"command": "pnpm",
"args": ["run", "demo:acp"],
"args": ["--dir", "/path/to/deepseek-harness", "run", "demo:acp"],
"env": { "DEEPSEEK_API_KEY": "sk-…" }
}
}
}
```
The editor sets each session's `cwd` to the project it opens; the agent's bash tools run there (see the per-session `cwd` note in `packages/acp`), so the server does not need to be launched in the workspace.
The editor sets each session's `cwd` to the project it opens; the agent's bash tools run there (see the per-session `cwd` note in `packages/acp`), so launch the server from the harness repo with `pnpm --dir …` and let ACP carry the workspace path per session.
## MVP limitations
The bridge supports N concurrent sessions per connection, each in its own workspace `cwd` (RFC 011). Remaining limits: text-only prompts, `additionalDirectories` rejected (a session operates in its single `cwd`), 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.
The bridge supports N concurrent sessions per connection, each in its own workspace `cwd` (RFC 011). Remaining limits: prompts support ACP's baseline `text` and `resource_link` blocks only, `additionalDirectories` and `mcpServers` are rejected, 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

@@ -1,4 +1,4 @@
import { pathToFileURL } from 'node:url'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
@@ -18,6 +18,8 @@ try {
// ENOENT (no .env) is fine — rely on the ambient environment.
}
process.chdir(fileURLToPath(new URL('../..', import.meta.url)))
const ctx = new Context()
ctx.baseUrl = pathToFileURL(import.meta.dirname).href + '/'

View File

@@ -27,11 +27,11 @@ import {
*/
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 (this test launches there and uses it as the session cwd; the
// bridge no longer requires cwd === the launch dir, but a temp dir keeps the
// test hermetic), where a bare `--import tsx` would not resolve from
// node_modules. import.meta.resolve gives the worktree's tsx regardless of cwd.
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
// Resolve tsx's loader to an ABSOLUTE path. The subprocess launches from the
// harness repo (so pnpm/package resolution is stable) while each ACP session's
// request cwd points at the temp workspace; import.meta.resolve gives the
// worktree's tsx regardless of launch cwd.
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
interface Spawned {
@@ -41,11 +41,11 @@ interface Spawned {
stderr: string[]
}
function spawnAcpAgent(cwd: string): Spawned {
function spawnAcpAgent(): Spawned {
const child = spawn(
process.execPath,
['--import', tsxLoader, startScript],
{ cwd, env: { ...process.env }, stdio: ['pipe', 'pipe', 'pipe'] },
{ cwd: repoRoot, env: { ...process.env }, stdio: ['pipe', 'pipe', 'pipe'] },
)
const stderr: string[] = []
child.stderr.setEncoding('utf8')
@@ -91,13 +91,16 @@ describe('acp-agent stdout purity (no key required)', () => {
// 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,
cwd: repoRoot,
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
stdio: ['pipe', 'pipe', 'pipe'],
})
const out: string[] = []
const stderr: string[] = []
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (c: string) => out.push(c))
child.stderr.on('data', (c: string) => stderr.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: {} } })
@@ -108,7 +111,7 @@ describe('acp-agent stdout purity (no key required)', () => {
child.kill('SIGKILL')
const lines = out.join('').split('\n').filter(l => l.trim().length > 0)
expect(lines.length).toBeGreaterThan(0)
expect(lines.length, stderr.join('')).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.
@@ -120,7 +123,7 @@ describe('acp-agent stdout purity (no key required)', () => {
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)
spawned = spawnAcpAgent()
const { client, updates } = spawned
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })