built-bin.e2e.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import { spawn } from 'node:child_process'
  2. import { mkdtemp, mkdir, readdir, rm, symlink, writeFile, readFile } from 'node:fs/promises'
  3. import { existsSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { dirname, join } from 'node:path'
  6. import { fileURLToPath, pathToFileURL } from 'node:url'
  7. import {
  8. ClientSideConnection,
  9. ndJsonStream,
  10. PROTOCOL_VERSION,
  11. type Agent as AcpAgent,
  12. type Client,
  13. type RequestPermissionRequest,
  14. type RequestPermissionResponse,
  15. type SessionNotification,
  16. } from '@agentclientprotocol/sdk'
  17. import { Readable, Writable } from 'node:stream'
  18. import { promisify } from 'node:util'
  19. import { zstdDecompress } from 'node:zlib'
  20. import { execa } from 'execa'
  21. import { afterEach, describe, expect, it } from 'vitest'
  22. /**
  23. * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
  24. * complete a mock-backed turn. This catches built-only settle races, stdout protocol leaks, and
  25. * published persistence behavior that the tsx source-path smoke cannot. It skips before build.
  26. */
  27. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  28. const acpBin = join(repoRoot, 'packages/examples/acp-demo/lib/bin.js')
  29. const decompress = promisify(zstdDecompress)
  30. const dshPackages = [
  31. 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
  32. 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
  33. 'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
  34. 'session-persistence/session-persistence',
  35. 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl',
  36. 'acp/acp', 'examples/acp-demo', 'util/paths',
  37. ]
  38. const vendorPackages = [
  39. 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
  40. 'schemastery', 'cosmokit',
  41. ]
  42. // Resolve ACP's declared third-party dependencies from that package, not this test: pnpm's strict
  43. // layout need not hoist them. Symlink those exact paths into the plain-Node consumer.
  44. const npmDeps = ['@agentclientprotocol/sdk']
  45. const acpPkgDir = join(repoRoot, 'packages/acp/acp')
  46. async function pkgName(absDir: string): Promise<string> {
  47. const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
  48. return json.name
  49. }
  50. async function link(target: string, name: string, nm: string): Promise<void> {
  51. const dest = join(nm, name)
  52. await mkdir(dirname(dest), { recursive: true })
  53. await symlink(target, dest)
  54. }
  55. /** Build a temp consumer dir + a minimal acp `cordis.yml`. Returns the dir. */
  56. async function makeConsumer(): Promise<string> {
  57. const dir = await mkdtemp(join(tmpdir(), 'acp-built-bin-'))
  58. const nm = join(dir, 'node_modules')
  59. for (const rel of dshPackages) {
  60. const abs = join(repoRoot, 'packages', rel)
  61. await link(abs, await pkgName(abs), nm)
  62. }
  63. for (const v of vendorPackages) {
  64. const abs = join(repoRoot, 'vendor', v)
  65. await link(abs, await pkgName(abs), nm)
  66. }
  67. for (const dep of npmDeps) {
  68. // Resolve from ACP's package.json URL (the package that declares the
  69. // dep), not this test file's location — `acp-agent` does not depend on these.
  70. const fromAcp = pathToFileURL(join(acpPkgDir, 'package.json')).href
  71. const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp))
  72. await link(dirname(resolved), dep, nm)
  73. }
  74. await writeFile(join(dir, 'mock-llm.mjs'), [
  75. "import { LlmAdapter } from '@deepseek-ai/dsh-llm'",
  76. 'class Mock extends LlmAdapter {',
  77. ' async * stream() {',
  78. " yield { type: 'block-start', index: 0, blockType: 'text' }",
  79. " yield { type: 'text-delta', index: 0, text: 'ACP BUILT OK' }",
  80. " yield { type: 'block-end', index: 0, block: { type: 'text', text: 'ACP BUILT OK' } }",
  81. " yield { type: 'finish', reason: { kind: 'stop' } }",
  82. ' }',
  83. '}',
  84. "export const name = 'built-acp-mock'",
  85. "export const inject = ['llm']",
  86. "export function apply(ctx) { ctx.llm.registerAdapter(['built-acp-mock'], new Mock()) }",
  87. '',
  88. ].join('\n'))
  89. await writeFile(join(dir, 'cordis.yml'), [
  90. '- id: mock-llm',
  91. ' name: \'./mock-llm.mjs\'',
  92. '- id: subprocess',
  93. ' name: \'@deepseek-ai/dsh-subprocess-local\'',
  94. '- id: bash',
  95. ' name: \'@deepseek-ai/dsh-bash-local\'',
  96. '- id: acp-agent',
  97. ' name: \'@deepseek-ai/dsh-acp-demo\'',
  98. ' config:',
  99. ' provider: built-acp-mock',
  100. ' model: built-acp-mock',
  101. ' persona: \'test agent\'',
  102. ' workspaceContext: false',
  103. '',
  104. ].join('\n'))
  105. return dir
  106. }
  107. let consumer: string | undefined
  108. let child: ReturnType<typeof spawn> | undefined
  109. afterEach(async () => {
  110. if (child !== undefined) {
  111. const proc = child
  112. child = undefined
  113. // Windows retains the child's cwd and session-log handles until process
  114. // teardown completes, so await exit before removing the temp directory.
  115. if (proc.exitCode === null && proc.signalCode === null) {
  116. const exited = new Promise<void>((resolve) => { proc.once('exit', () => { resolve() }) })
  117. proc.kill('SIGKILL')
  118. await exited
  119. }
  120. }
  121. // Windows can briefly retain released handles after exit; retry removal.
  122. if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
  123. consumer = undefined
  124. })
  125. describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, no tsx)', () => {
  126. it('boots the published bin, completes a turn, and writes default Zstandard persistence', async () => {
  127. consumer = await makeConsumer()
  128. child = spawn(process.execPath, [acpBin, '--config', './cordis.yml'], {
  129. cwd: consumer,
  130. env: {
  131. ...process.env,
  132. DSH_HOME: join(consumer, '.dsh'),
  133. DSH_AGENTS_HOME: join(consumer, '.agents'),
  134. },
  135. stdio: ['pipe', 'pipe', 'pipe'],
  136. })
  137. const stderr: string[] = []
  138. child.stderr!.setEncoding('utf8')
  139. child.stderr!.on('data', (c: string) => stderr.push(c))
  140. // Tee raw stdout for a protocol-purity check, and feed it to the SDK client.
  141. const rawOut: string[] = []
  142. const passthrough = new Readable({ read() {} })
  143. child.stdout!.on('data', (buf: Buffer) => { rawOut.push(buf.toString('utf8')); passthrough.push(buf) })
  144. child.stdout!.on('end', () => passthrough.push(null))
  145. const stream = ndJsonStream(
  146. Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
  147. Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
  148. )
  149. const updates: SessionNotification['update'][] = []
  150. const makeClient = (_a: AcpAgent): Client => ({
  151. sessionUpdate(params: SessionNotification): Promise<void> {
  152. updates.push(params.update)
  153. return Promise.resolve()
  154. },
  155. requestPermission(_p: RequestPermissionRequest): Promise<RequestPermissionResponse> {
  156. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  157. },
  158. })
  159. const client = new ClientSideConnection(makeClient, stream)
  160. const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  161. expect(init.agentCapabilities).toEqual({
  162. promptCapabilities: { image: false, audio: false, embeddedContext: false },
  163. })
  164. const sessionCwd = consumer
  165. const { sessionId } = await client.newSession({ cwd: sessionCwd, mcpServers: [] })
  166. const result = await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'reply' }] })
  167. expect(result.stopReason).toBe('end_turn')
  168. await expect.poll(() => updates).toEqual([{
  169. sessionUpdate: 'agent_message_chunk',
  170. content: { type: 'text', text: 'ACP BUILT OK' },
  171. }])
  172. const sessionsRoot = join(sessionCwd, '.sessions')
  173. let log: string | undefined
  174. await expect.poll(async () => {
  175. log = (await readdir(sessionsRoot, { recursive: true })).find(file => file.endsWith('.jsonl.zstd'))
  176. return log
  177. }).toBeTypeOf('string')
  178. const compressed = await readFile(join(sessionsRoot, log!))
  179. expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
  180. expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: sessionId })
  181. expect(stderr.join('')).not.toContain('without inject')
  182. // stdout purity: every emitted line is a JSON-RPC frame, no logger leak.
  183. for (const line of rawOut.join('').split('\n').filter(l => l.trim().length > 0)) {
  184. expect(() => JSON.parse(line) as unknown).not.toThrow()
  185. }
  186. }, 30_000)
  187. it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
  188. // boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config
  189. // directory cannot break its import; the include plugin's own read must fail loud instead.
  190. const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml')
  191. expect(code).not.toBe(0)
  192. expect(stderr).toContain('config file not found')
  193. }, 30_000)
  194. it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
  195. // Existing directory plus missing config exercises the include plugin's fail-loud path.
  196. consumer = await makeConsumer()
  197. const { code, stderr } = await runBinExpectingExit('./does-not-exist.yml', consumer)
  198. expect(code).not.toBe(0)
  199. expect(stderr).toContain('config file not found')
  200. }, 30_000)
  201. })
  202. /** Spawn the built acp bin against `configArg` (stdin closed at EOF) and resolve with its exit code + stderr. */
  203. async function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
  204. const result = await execa(process.execPath, [acpBin, '--config', configArg], {
  205. cwd,
  206. env: {
  207. DSH_HOME: join(cwd, '.dsh'),
  208. DSH_AGENTS_HOME: join(cwd, '.agents'),
  209. },
  210. input: '',
  211. timeout: 25_000,
  212. killSignal: 'SIGKILL',
  213. reject: false,
  214. })
  215. if (result.timedOut) throw new Error(`bin did not exit within 25s. stderr:\n${result.stderr}`)
  216. return { code: result.exitCode ?? -1, stderr: result.stderr }
  217. }