built-bin.e2e.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import { spawn } from 'node:child_process'
  2. import { mkdtemp, mkdir, 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 { afterEach, describe, expect, it } from 'vitest'
  19. /**
  20. * BUILT-ARTIFACT smoke for the published `dsh-acp-agent` bin. `load-path.e2e.ts`
  21. * boots `src/bin.ts` under tsx — but the package's `bin` field points at
  22. * `lib/bin.js`, run under plain `node` by a real consumer. This runs the REAL
  23. * `lib/bin.js` under `node` (NOT tsx) and asserts it answers an `initialize`
  24. * JSON-RPC frame, so a regression in the published entry (a settle race that
  25. * exits before the bridge attaches, a stdout logger leaking onto the protocol)
  26. * fails here.
  27. *
  28. * It build-gates: SKIPS if `lib/bin.js` is absent (suite run without
  29. * `pnpm run build`); CI runs it after the build step. Setup mirrors a real
  30. * install (a temp dir whose `node_modules` symlinks the built packages) and runs
  31. * `node --expose-internals` (the cordis Loader resolves bare plugin specifiers
  32. * via its internal module loader, active only under that flag). KEYLESS:
  33. * `initialize` never reaches the model; a dummy key lets `llm-deepseek` boot.
  34. */
  35. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  36. const acpBin = join(repoRoot, 'packages/ui/acp-agent/lib/bin.js')
  37. const dshPackages = [
  38. 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
  39. 'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
  40. 'bash/bash-local', 'bash/tool-bash', 'support/invariants',
  41. 'session-persistence/session-persistence',
  42. 'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent',
  43. ]
  44. const vendorPackages = [
  45. 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
  46. 'schemastery', 'cosmokit',
  47. ]
  48. // Third-party deps the ACP bridge needs at runtime. They are declared by
  49. // `dsh-acp` (NOT by `acp-agent`), so they live under `packages/ui/acp/node_modules`
  50. // and are NOT necessarily hoisted where THIS test file can resolve them — pnpm's
  51. // strict layout only exposes a package's deps under that package. Resolve each
  52. // from the `ui/acp` package directory (the one that declares it) so the lookup
  53. // works regardless of hoisting, then symlink it into the consumer for plain node.
  54. const npmDeps = ['@agentclientprotocol/sdk', 'zod']
  55. const acpPkgDir = join(repoRoot, 'packages/ui/acp')
  56. async function pkgName(absDir: string): Promise<string> {
  57. const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
  58. return json.name
  59. }
  60. async function link(target: string, name: string, nm: string): Promise<void> {
  61. const dest = join(nm, name)
  62. await mkdir(dirname(dest), { recursive: true })
  63. await symlink(target, dest)
  64. }
  65. /** Build a temp consumer dir + a minimal acp `cordis.yml`. Returns the dir. */
  66. async function makeConsumer(): Promise<string> {
  67. const dir = await mkdtemp(join(tmpdir(), 'acp-built-bin-'))
  68. const nm = join(dir, 'node_modules')
  69. for (const rel of dshPackages) {
  70. const abs = join(repoRoot, 'packages', rel)
  71. await link(abs, await pkgName(abs), nm)
  72. }
  73. for (const v of vendorPackages) {
  74. const abs = join(repoRoot, 'vendor', v)
  75. await link(abs, await pkgName(abs), nm)
  76. }
  77. for (const dep of npmDeps) {
  78. // Resolve from `ui/acp`'s package.json URL (the package that declares the
  79. // dep), not this test file's location — `acp-agent` does not depend on these.
  80. const fromAcp = pathToFileURL(join(acpPkgDir, 'package.json')).href
  81. const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp))
  82. await link(dirname(resolved), dep, nm)
  83. }
  84. await writeFile(join(dir, 'cordis.yml'), [
  85. '- id: llm-deepseek',
  86. ' name: \'@deepseek-ai/dsh-llm-deepseek\'',
  87. ' config:',
  88. ' apiKey: !!js process.env.DEEPSEEK_API_KEY',
  89. ' models: [deepseek-v4-flash]',
  90. '- id: bash',
  91. ' name: \'@deepseek-ai/dsh-bash-local\'',
  92. '- id: acp-agent',
  93. ' name: \'@deepseek-ai/dsh-acp-agent\'',
  94. ' config:',
  95. ' model: deepseek-v4-flash',
  96. ' systemPrompt: \'test agent\'',
  97. '',
  98. ].join('\n'))
  99. return dir
  100. }
  101. let consumer: string | undefined
  102. let child: ReturnType<typeof spawn> | undefined
  103. afterEach(async () => {
  104. if (child !== undefined) { child.kill('SIGKILL'); child = undefined }
  105. if (consumer !== undefined) await rm(consumer, { recursive: true, force: true })
  106. consumer = undefined
  107. })
  108. describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, no tsx)', () => {
  109. it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => {
  110. consumer = await makeConsumer()
  111. child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], {
  112. cwd: consumer,
  113. // Dummy key: initialize never reaches the model, so it is never used.
  114. env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
  115. stdio: ['pipe', 'pipe', 'pipe'],
  116. })
  117. const stderr: string[] = []
  118. child.stderr!.setEncoding('utf8')
  119. child.stderr!.on('data', (c: string) => stderr.push(c))
  120. // Tee raw stdout for a protocol-purity check, and feed it to the SDK client.
  121. const rawOut: string[] = []
  122. const passthrough = new Readable({ read() {} })
  123. child.stdout!.on('data', (buf: Buffer) => { rawOut.push(buf.toString('utf8')); passthrough.push(buf) })
  124. child.stdout!.on('end', () => passthrough.push(null))
  125. const stream = ndJsonStream(
  126. Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
  127. Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
  128. )
  129. const makeClient = (_a: AcpAgent): Client => ({
  130. sessionUpdate(_p: SessionNotification): Promise<void> { return Promise.resolve() },
  131. requestPermission(_p: RequestPermissionRequest): Promise<RequestPermissionResponse> {
  132. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  133. },
  134. })
  135. const client = new ClientSideConnection(makeClient, stream)
  136. const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  137. // A response at all proves the built bin booted the bridge (the settle-race
  138. // regression would exit before answering); loadSession proves the real app
  139. // mounted, not a collapsed export shape.
  140. expect(init.agentCapabilities?.loadSession).toBe(true)
  141. expect(stderr.join('')).not.toContain('without inject')
  142. // stdout purity: every emitted line is a JSON-RPC frame, no logger leak.
  143. for (const line of rawOut.join('').split('\n').filter(l => l.trim().length > 0)) {
  144. expect(() => JSON.parse(line) as unknown).not.toThrow()
  145. }
  146. }, 30_000)
  147. it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
  148. // A typo'd config path must fail clearly, not exit 0. The include plugin
  149. // itself cannot be imported from a non-existent dir; the Loader logs that and
  150. // leaves the entry with no fiber, which boot()'s entry-load check throws on.
  151. const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml')
  152. expect(code).not.toBe(0)
  153. expect(stderr).toContain('failed to load')
  154. }, 30_000)
  155. it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
  156. // The directory exists (the include imports), but the file does not — the
  157. // include's init throws "config file not found", which surfaces as an
  158. // unhandled rejection the fail-loud guard turns into a non-zero exit.
  159. consumer = await makeConsumer()
  160. const { code, stderr } = await runBinExpectingExit('./does-not-exist.yml', consumer)
  161. expect(code).not.toBe(0)
  162. expect(stderr).toContain('config file not found')
  163. }, 30_000)
  164. })
  165. /** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
  166. function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
  167. return new Promise((resolve, reject) => {
  168. const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
  169. cwd,
  170. env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
  171. stdio: ['pipe', 'pipe', 'pipe'],
  172. })
  173. child = proc
  174. let stderr = ''
  175. proc.stderr.setEncoding('utf8')
  176. proc.stderr.on('data', (c: string) => { stderr += c })
  177. const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000)
  178. proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) })
  179. proc.on('error', (err) => { clearTimeout(timer); reject(err) })
  180. proc.stdin.end()
  181. })
  182. }