built-bin.e2e.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  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. * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
  21. * require a valid initialize response. This catches built-only settle races and stdout protocol
  22. * leaks that the tsx source-path smoke cannot. It skips before build; initialize is keyless, with a
  23. * dummy key used only to boot the adapter. `--expose-internals` enables Cordis bare-plugin loading.
  24. */
  25. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  26. const acpBin = join(repoRoot, 'packages/examples/acp-demo/lib/bin.js')
  27. const dshPackages = [
  28. 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
  29. 'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
  30. 'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
  31. 'session-persistence/session-persistence',
  32. 'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', 'util/paths',
  33. ]
  34. const vendorPackages = [
  35. 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
  36. 'schemastery', 'cosmokit',
  37. ]
  38. // Resolve ACP's declared third-party dependencies from that package, not this test: pnpm's strict
  39. // layout need not hoist them. Symlink those exact paths into the plain-Node consumer.
  40. const npmDeps = ['@agentclientprotocol/sdk', 'zod']
  41. const acpPkgDir = join(repoRoot, 'packages/ui/acp')
  42. async function pkgName(absDir: string): Promise<string> {
  43. const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
  44. return json.name
  45. }
  46. async function link(target: string, name: string, nm: string): Promise<void> {
  47. const dest = join(nm, name)
  48. await mkdir(dirname(dest), { recursive: true })
  49. await symlink(target, dest)
  50. }
  51. /** Build a temp consumer dir + a minimal acp `cordis.yml`. Returns the dir. */
  52. async function makeConsumer(): Promise<string> {
  53. const dir = await mkdtemp(join(tmpdir(), 'acp-built-bin-'))
  54. const nm = join(dir, 'node_modules')
  55. for (const rel of dshPackages) {
  56. const abs = join(repoRoot, 'packages', rel)
  57. await link(abs, await pkgName(abs), nm)
  58. }
  59. for (const v of vendorPackages) {
  60. const abs = join(repoRoot, 'vendor', v)
  61. await link(abs, await pkgName(abs), nm)
  62. }
  63. for (const dep of npmDeps) {
  64. // Resolve from `ui/acp`'s package.json URL (the package that declares the
  65. // dep), not this test file's location — `acp-agent` does not depend on these.
  66. const fromAcp = pathToFileURL(join(acpPkgDir, 'package.json')).href
  67. const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp))
  68. await link(dirname(resolved), dep, nm)
  69. }
  70. await writeFile(join(dir, 'cordis.yml'), [
  71. '- id: llm-deepseek',
  72. ' name: \'@deepseek-ai/dsh-llm-deepseek\'',
  73. ' config:',
  74. ' apiKey: !!js process.env.DEEPSEEK_API_KEY',
  75. '- id: bash',
  76. ' name: \'@deepseek-ai/dsh-bash-local\'',
  77. '- id: acp-agent',
  78. ' name: \'@deepseek-ai/dsh-acp-demo\'',
  79. ' config:',
  80. ' provider: deepseek',
  81. ' model: deepseek-v4-flash',
  82. ' persona: \'test agent\'',
  83. ' workspaceContext: false',
  84. '',
  85. ].join('\n'))
  86. return dir
  87. }
  88. let consumer: string | undefined
  89. let child: ReturnType<typeof spawn> | undefined
  90. afterEach(async () => {
  91. if (child !== undefined) {
  92. const proc = child
  93. child = undefined
  94. // Windows retains the child's cwd and session-log handles until process
  95. // teardown completes, so await exit before removing the temp directory.
  96. if (proc.exitCode === null && proc.signalCode === null) {
  97. const exited = new Promise<void>((resolve) => { proc.once('exit', () => { resolve() }) })
  98. proc.kill('SIGKILL')
  99. await exited
  100. }
  101. }
  102. // Windows can briefly retain released handles after exit; retry removal.
  103. if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
  104. consumer = undefined
  105. })
  106. describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, no tsx)', () => {
  107. it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => {
  108. consumer = await makeConsumer()
  109. child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], {
  110. cwd: consumer,
  111. // Dummy key: initialize never reaches the model, so it is never used.
  112. env: {
  113. ...process.env,
  114. DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
  115. DSH_HOME: join(consumer, '.dsh'),
  116. DSH_AGENTS_HOME: join(consumer, '.agents'),
  117. },
  118. stdio: ['pipe', 'pipe', 'pipe'],
  119. })
  120. const stderr: string[] = []
  121. child.stderr!.setEncoding('utf8')
  122. child.stderr!.on('data', (c: string) => stderr.push(c))
  123. // Tee raw stdout for a protocol-purity check, and feed it to the SDK client.
  124. const rawOut: string[] = []
  125. const passthrough = new Readable({ read() {} })
  126. child.stdout!.on('data', (buf: Buffer) => { rawOut.push(buf.toString('utf8')); passthrough.push(buf) })
  127. child.stdout!.on('end', () => passthrough.push(null))
  128. const stream = ndJsonStream(
  129. Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
  130. Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
  131. )
  132. const makeClient = (_a: AcpAgent): Client => ({
  133. sessionUpdate(_p: SessionNotification): Promise<void> { return Promise.resolve() },
  134. requestPermission(_p: RequestPermissionRequest): Promise<RequestPermissionResponse> {
  135. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  136. },
  137. })
  138. const client = new ClientSideConnection(makeClient, stream)
  139. const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  140. // A response at all proves the built bin booted the bridge (the settle-race
  141. // regression would exit before answering); loadSession proves the real app
  142. // mounted, not a collapsed export shape.
  143. expect(init.agentCapabilities?.loadSession).toBe(true)
  144. expect(stderr.join('')).not.toContain('without inject')
  145. // stdout purity: every emitted line is a JSON-RPC frame, no logger leak.
  146. for (const line of rawOut.join('').split('\n').filter(l => l.trim().length > 0)) {
  147. expect(() => JSON.parse(line) as unknown).not.toThrow()
  148. }
  149. }, 30_000)
  150. it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
  151. // boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config
  152. // directory cannot break its import; the include plugin's own read must fail loud instead.
  153. const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml')
  154. expect(code).not.toBe(0)
  155. expect(stderr).toContain('config file not found')
  156. }, 30_000)
  157. it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
  158. // Existing directory plus missing config exercises the include plugin's fail-loud path.
  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, '--config', configArg], {
  169. cwd,
  170. env: {
  171. ...process.env,
  172. DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
  173. DSH_HOME: join(cwd, '.dsh'),
  174. DSH_AGENTS_HOME: join(cwd, '.agents'),
  175. },
  176. stdio: ['pipe', 'pipe', 'pipe'],
  177. })
  178. child = proc
  179. let stderr = ''
  180. proc.stderr.setEncoding('utf8')
  181. proc.stderr.on('data', (c: string) => { stderr += c })
  182. const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000)
  183. proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) })
  184. proc.on('error', (err) => { clearTimeout(timer); reject(err) })
  185. proc.stdin.end()
  186. })
  187. }