mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Two independent bugs made the ACP server crash the moment an editor (Zed)
connected, despite 178 green unit tests at 100% coverage:
1. `session/new` threw `cannot get property "agents" without inject`. Root
cause: a stray `export default apply` made the cordis Loader's
`unwrapExports` (`exports.default ?? exports`) collapse the module to the
bare `apply` function, discarding the sibling `inject`/`name`/`Config`
named exports. The plugin fiber was built with empty `inject`, so every
`ctx.<service>` read in `apply` threw at load. Fix: remove the default
export so the Loader uses the namespace.
2. `session/load` threw `cannot get property "sessionPersistence" without
inject`. `AgentLoop.resume` read `this.ctx.sessionPersistence` (a service
it deliberately does NOT inject); the property proxy's ancestor-only fiber
walk fails through the bridge's traceable shadow. Fix: read it via
`this.ctx.get('sessionPersistence', false)`, the topology-independent
global-store lookup.
Why the suite missed both: every test mounted the plugin by hand
(`ctx.plugin({name,inject,apply})`), bypassing `unwrapExports` entirely, and
the only test driving these RPCs was key-gated (skipped in CI). Added a no-key
`session/new` e2e that boots the real example through the real Loader — it
fails loudly on bug #1 without an API key. Set `TSX_TSCONFIG_PATH` in the e2e
spawn so the subprocess resolves workspace `paths` from a temp cwd (it was
silently falling back to a stale built `lib/`).
Docs: post-mortem 0001; AGENTS.md "line coverage is not behavior coverage" +
with-key/smoke-test philosophy; packages/AGENTS.md plugin-export-shape and
ctx.get rules; dsh-code-review SKILL checks.
178 lines
8.4 KiB
TypeScript
178 lines
8.4 KiB
TypeScript
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'))
|
|
// Absolute path to the repo-root tsconfig. Dev/test/demo run UNBUILT: the
|
|
// `@deepseek-ai/dsh-*` workspace imports resolve through the `paths` map in the
|
|
// root tsconfig (tsx reads it), NOT through built `lib/` output. But tsx finds
|
|
// that tsconfig by searching UP from the child's cwd — and the child's cwd is a
|
|
// temp workdir OUTSIDE the repo, so the search misses and the dsh-* imports fail
|
|
// (the child dies before writing a byte). Point tsx at the repo tsconfig
|
|
// explicitly via TSX_TSCONFIG_PATH so resolution is cwd-independent. (Without
|
|
// this the suite only passed by accident when a stale built `lib/` happened to
|
|
// exist — exactly the contamination that masked the inject bug this suite now
|
|
// guards.) The repo root is four levels up from this file (examples/acp-agent/tests).
|
|
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
|
|
|
interface Spawned {
|
|
child: ChildProcessWithoutNullStreams
|
|
client: ClientSideConnection
|
|
updates: SessionNotification['update'][]
|
|
stderr: string[]
|
|
}
|
|
|
|
function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned {
|
|
const child = spawn(
|
|
process.execPath,
|
|
['--import', tsxLoader, startScript],
|
|
{ cwd, env: { ...env, TSX_TSCONFIG_PATH: repoTsconfig }, 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 over real stdio (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', TSX_TSCONFIG_PATH: repoTsconfig },
|
|
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)
|
|
|
|
it('session/new succeeds over real stdio (no model call)', async () => {
|
|
// REGRESSION GUARD (this exact RPC crashed a real Zed session with
|
|
// "cannot get property \"agents\" without inject"): `session/new` drives the
|
|
// full bridge → `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop →
|
|
// registry/persistence path, ALL of which run from the JSON-RPC read loop
|
|
// OUTSIDE the bridge plugin's injection scope. A lazy `ctx.<service>` read
|
|
// on that path throws and the RPC fails with an Internal error — yet the
|
|
// call never touches the model, so this reproduces WITHOUT a key. The
|
|
// key-gated prompt test below never caught it (it needs real creds); the
|
|
// initialize-only purity test never caught it (initialize does not reach
|
|
// the factory). This closes that gap: boot the real subprocess and create a
|
|
// session, asserting the RPC RESOLVES (not rejects with an inject error).
|
|
workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
|
|
// A dummy key lets the deepseek adapter boot (it only checks presence, not
|
|
// validity, at apply time); no model call is made, so the key is never used.
|
|
spawned = spawnAcpAgent(workdir, { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' })
|
|
const { client } = spawned
|
|
|
|
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
|
const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
|
|
expect(typeof sessionId).toBe('string')
|
|
expect(sessionId.length).toBeGreaterThan(0)
|
|
}, 60_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)
|
|
})
|