Files
deepseek-harness/packages/ui/acp-agent/tests/built-bin.e2e.ts

189 lines
8.2 KiB
TypeScript

import { spawn } from 'node:child_process'
import { mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
type Agent as AcpAgent,
type Client,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
} from '@agentclientprotocol/sdk'
import { Readable, Writable } from 'node:stream'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
* require a valid initialize response. This catches built-only settle races and stdout protocol
* leaks that the tsx source-path smoke cannot. It skips before build; initialize is keyless, with a
* dummy key used only to boot the adapter. `--expose-internals` enables Cordis bare-plugin loading.
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const acpBin = join(repoRoot, 'packages/ui/acp-agent/lib/bin.js')
const dshPackages = [
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent',
]
const vendorPackages = [
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
'schemastery', 'cosmokit',
]
// Resolve ACP's declared third-party dependencies from that package, not this test: pnpm's strict
// layout need not hoist them. Symlink those exact paths into the plain-Node consumer.
const npmDeps = ['@agentclientprotocol/sdk', 'zod']
const acpPkgDir = join(repoRoot, 'packages/ui/acp')
async function pkgName(absDir: string): Promise<string> {
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
return json.name
}
async function link(target: string, name: string, nm: string): Promise<void> {
const dest = join(nm, name)
await mkdir(dirname(dest), { recursive: true })
await symlink(target, dest)
}
/** Build a temp consumer dir + a minimal acp `cordis.yml`. Returns the dir. */
async function makeConsumer(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'acp-built-bin-'))
const nm = join(dir, 'node_modules')
for (const rel of dshPackages) {
const abs = join(repoRoot, 'packages', rel)
await link(abs, await pkgName(abs), nm)
}
for (const v of vendorPackages) {
const abs = join(repoRoot, 'vendor', v)
await link(abs, await pkgName(abs), nm)
}
for (const dep of npmDeps) {
// Resolve from `ui/acp`'s package.json URL (the package that declares the
// dep), not this test file's location — `acp-agent` does not depend on these.
const fromAcp = pathToFileURL(join(acpPkgDir, 'package.json')).href
const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp))
await link(dirname(resolved), dep, nm)
}
await writeFile(join(dir, 'cordis.yml'), [
'- id: llm-deepseek',
' name: \'@deepseek-ai/dsh-llm-deepseek\'',
' config:',
' apiKey: !!js process.env.DEEPSEEK_API_KEY',
' models: [deepseek-v4-flash]',
'- id: bash',
' name: \'@deepseek-ai/dsh-bash-local\'',
'- id: acp-agent',
' name: \'@deepseek-ai/dsh-acp-agent\'',
' config:',
' model: deepseek-v4-flash',
' persona: \'test agent\'',
'',
].join('\n'))
return dir
}
let consumer: string | undefined
let child: ReturnType<typeof spawn> | undefined
afterEach(async () => {
if (child !== undefined) { child.kill('SIGKILL'); child = undefined }
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true })
consumer = undefined
})
describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, no tsx)', () => {
it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => {
consumer = await makeConsumer()
child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], {
cwd: consumer,
// Dummy key: initialize never reaches the model, so it is never used.
env: {
...process.env,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
DSH_HOME: join(consumer, '.dsh'),
DSH_AGENTS_HOME: join(consumer, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
})
const stderr: string[] = []
child.stderr!.setEncoding('utf8')
child.stderr!.on('data', (c: string) => stderr.push(c))
// Tee raw stdout for a protocol-purity check, and feed it to the SDK client.
const rawOut: string[] = []
const passthrough = new Readable({ read() {} })
child.stdout!.on('data', (buf: Buffer) => { rawOut.push(buf.toString('utf8')); passthrough.push(buf) })
child.stdout!.on('end', () => passthrough.push(null))
const stream = ndJsonStream(
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
)
const makeClient = (_a: AcpAgent): Client => ({
sessionUpdate(_p: SessionNotification): Promise<void> { return Promise.resolve() },
requestPermission(_p: RequestPermissionRequest): Promise<RequestPermissionResponse> {
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
},
})
const client = new ClientSideConnection(makeClient, stream)
const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
// A response at all proves the built bin booted the bridge (the settle-race
// regression would exit before answering); loadSession proves the real app
// mounted, not a collapsed export shape.
expect(init.agentCapabilities?.loadSession).toBe(true)
expect(stderr.join('')).not.toContain('without inject')
// stdout purity: every emitted line is a JSON-RPC frame, no logger leak.
for (const line of rawOut.join('').split('\n').filter(l => l.trim().length > 0)) {
expect(() => JSON.parse(line) as unknown).not.toThrow()
}
}, 30_000)
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
// boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config
// directory cannot break its import; the include plugin's own read must fail loud instead.
const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml')
expect(code).not.toBe(0)
expect(stderr).toContain('config file not found')
}, 30_000)
it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
// Existing directory plus missing config exercises the include plugin's fail-loud path.
consumer = await makeConsumer()
const { code, stderr } = await runBinExpectingExit('./does-not-exist.yml', consumer)
expect(code).not.toBe(0)
expect(stderr).toContain('config file not found')
}, 30_000)
})
/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
return new Promise((resolve, reject) => {
const proc = spawn(process.execPath, ['--expose-internals', acpBin, '--config', configArg], {
cwd,
env: {
...process.env,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
})
child = proc
let stderr = ''
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (c: string) => { stderr += c })
const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000)
proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) })
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
proc.stdin.end()
})
}