acp.e2e.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
  2. import { Readable, Writable } from 'node:stream'
  3. import { mkdtemp, rm, readFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { fileURLToPath } from 'node:url'
  7. import { afterEach, describe, expect, it } from 'vitest'
  8. import {
  9. ClientSideConnection,
  10. ndJsonStream,
  11. PROTOCOL_VERSION,
  12. type Agent as AcpAgent,
  13. type Client,
  14. type RequestPermissionRequest,
  15. type RequestPermissionResponse,
  16. type SessionNotification,
  17. } from '@agentclientprotocol/sdk'
  18. /**
  19. * Boots examples/acp-agent as an ACP subprocess. The key-gated prompt leg
  20. * verifies its filesystem effect; a keyless initialize leg verifies that stdout
  21. * contains only framed JSON-RPC. Each subprocess is disposed in `afterEach`.
  22. */
  23. // The child runs from a temp cwd, so its bin and config path are absolute.
  24. const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url))
  25. const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
  26. // Resolve tsx absolutely because the subprocess runs outside the repo.
  27. const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
  28. // The root tsconfig supplies unbuilt workspace `paths`; making it explicit
  29. // avoids accidental resolution through stale built output.
  30. const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  31. interface Spawned {
  32. child: ChildProcessWithoutNullStreams
  33. client: ClientSideConnection
  34. updates: SessionNotification['update'][]
  35. stderr: string[]
  36. }
  37. // TODO(acp-test-harness): this subprocess/client boot glue is duplicated with
  38. // hooks.e2e.ts and partly with dsh-acp-snapshot's harness. Migrate both e2e
  39. // files onto that launcher before the TSX/env/permission-stub details drift.
  40. function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned {
  41. const child = spawn(
  42. process.execPath,
  43. ['--import', tsxLoader, binScript, '--config', configPath],
  44. {
  45. cwd,
  46. env: {
  47. ...env,
  48. TSX_TSCONFIG_PATH: repoTsconfig,
  49. DSH_PERMISSION_MODE: 'danger-full-access',
  50. DSH_HOME: join(cwd, '.dsh'),
  51. DSH_AGENTS_HOME: join(cwd, '.agents'),
  52. },
  53. stdio: ['pipe', 'pipe', 'pipe'],
  54. },
  55. )
  56. const stderr: string[] = []
  57. child.stderr.setEncoding('utf8')
  58. child.stderr.on('data', (chunk: string) => stderr.push(chunk))
  59. const updates: SessionNotification['update'][] = []
  60. const stream = ndJsonStream(
  61. Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  62. Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
  63. )
  64. const makeClient = (_agent: AcpAgent): Client => ({
  65. sessionUpdate(params: SessionNotification): Promise<void> {
  66. updates.push(params.update)
  67. return Promise.resolve()
  68. },
  69. requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
  70. // This suite selects danger-full-access (approval never), so the bridge
  71. // never prompts here; answer cancelled if an unexpected ask arrives.
  72. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  73. },
  74. })
  75. const client = new ClientSideConnection(makeClient, stream)
  76. return { child, client, updates, stderr }
  77. }
  78. let spawned: Spawned | undefined
  79. let workdir: string | undefined
  80. function hasStdoutLine(out: string[]): boolean {
  81. return out.join('').split('\n').some(line => line.trim().length > 0)
  82. }
  83. async function waitForStdoutLine(child: ChildProcessWithoutNullStreams, out: string[], stderr: string[], timeoutMs: number): Promise<void> {
  84. await new Promise<void>((resolve, reject) => {
  85. const cleanup = () => {
  86. clearTimeout(timeout)
  87. child.stdout.off('data', onData)
  88. child.off('exit', onExit)
  89. child.off('error', onError)
  90. }
  91. const pass = () => {
  92. cleanup()
  93. resolve()
  94. }
  95. const fail = (reason: string) => {
  96. cleanup()
  97. reject(new Error(`${reason}; stderr: ${stderr.join('')}`))
  98. }
  99. const onData = () => {
  100. if (hasStdoutLine(out)) pass()
  101. }
  102. const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
  103. fail(`ACP child exited before emitting a stdout frame (code ${code ?? 'null'}, signal ${signal ?? 'null'})`)
  104. }
  105. const onError = (error: Error) => {
  106. fail(`ACP child failed before emitting a stdout frame: ${error.message}`)
  107. }
  108. const timeout = setTimeout(() => {
  109. fail(`ACP child did not emit a stdout frame within ${timeoutMs}ms`)
  110. }, timeoutMs)
  111. child.stdout.on('data', onData)
  112. child.on('exit', onExit)
  113. child.on('error', onError)
  114. onData()
  115. })
  116. }
  117. afterEach(async () => {
  118. if (spawned) {
  119. spawned.child.kill('SIGKILL')
  120. spawned = undefined
  121. }
  122. if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
  123. workdir = undefined
  124. })
  125. describe('acp-agent over real stdio (no key required)', () => {
  126. it('emits only framed JSON-RPC on stdout', async () => {
  127. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  128. // Collect raw stdout bytes directly (bypass the SDK framing) to inspect.
  129. // A dummy key boots the adapter; this purity test sends no prompt and makes no model call.
  130. const child = spawn(process.execPath, ['--import', tsxLoader, binScript, '--config', configPath], {
  131. cwd: workdir,
  132. env: {
  133. ...process.env,
  134. DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
  135. TSX_TSCONFIG_PATH: repoTsconfig,
  136. DSH_PERMISSION_MODE: 'danger-full-access',
  137. DSH_HOME: join(workdir, '.dsh'),
  138. DSH_AGENTS_HOME: join(workdir, '.agents'),
  139. },
  140. stdio: ['pipe', 'pipe', 'pipe'],
  141. })
  142. const out: string[] = []
  143. const stderr: string[] = []
  144. child.stdout.setEncoding('utf8')
  145. child.stderr.setEncoding('utf8')
  146. child.stdout.on('data', (c: string) => out.push(c))
  147. child.stderr.on('data', (c: string) => stderr.push(c))
  148. // Send a single initialize request as a newline-delimited JSON-RPC frame.
  149. const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } })
  150. child.stdin.write(req + '\n')
  151. try {
  152. await waitForStdoutLine(child, out, stderr, 15_000)
  153. } finally {
  154. child.kill('SIGKILL')
  155. }
  156. const lines = out.join('').split('\n').filter(l => l.trim().length > 0)
  157. expect(lines.length).toBeGreaterThan(0)
  158. for (const line of lines) {
  159. // Every stdout line MUST parse as JSON (a JSON-RPC frame). A non-JSON
  160. // line means a logger/print leaked onto the protocol channel.
  161. expect(() => JSON.parse(line) as unknown).not.toThrow()
  162. }
  163. }, 30_000)
  164. it('session/new succeeds over real stdio (no model call)', async () => {
  165. // Regression guard (this exact RPC crashed a real Zed session with "cannot get property
  166. // \"agents\" without inject"): `session/new` drives the full bridge →
  167. // `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop → registry/persistence path, ALL
  168. // of which run from the JSON-RPC read loop outside the bridge plugin's injection scope.
  169. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  170. // A dummy key lets the deepseek adapter boot (it only checks presence, not
  171. // validity, at apply time); no model call is made, so the key is never used.
  172. spawned = spawnAcpAgent(workdir, { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' })
  173. const { client } = spawned
  174. await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  175. const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
  176. expect(typeof sessionId).toBe('string')
  177. expect(sessionId.length).toBeGreaterThan(0)
  178. }, 60_000)
  179. })
  180. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over ACP', () => {
  181. it('runs a real turn and the agent writes the requested file (verified on disk)', async () => {
  182. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  183. spawned = spawnAcpAgent(workdir)
  184. const { client, updates } = spawned
  185. await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  186. // Any absolute cwd is honored now; use the temp `workdir` as this session's
  187. // workspace (the bash tool will run there) — it need not equal the launch dir.
  188. const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
  189. const res = await client.prompt({
  190. sessionId,
  191. 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.' }],
  192. })
  193. expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
  194. // Verify the filesystem effect rather than the agent's report.
  195. const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
  196. expect(proof).toContain('ACP_OK')
  197. const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call')
  198. expect(toolCalls.length).toBeGreaterThan(0)
  199. // Bash execute cards hide rawInput, so `presentCall` uses the exact command
  200. // as the title rather than the bare tool name "bash".
  201. const bashCall = toolCalls.find(u => u.kind === 'execute')
  202. expect(bashCall).toBeDefined()
  203. if (bashCall === undefined) throw new Error('expected an execute tool_call')
  204. expect(typeof bashCall.title).toBe('string')
  205. expect(bashCall.title.length).toBeGreaterThan(0)
  206. expect(bashCall.title).not.toBe('bash')
  207. expect(typeof bashCall.rawInput).toBe('string')
  208. // Without the terminal capability, output uses the console-text path.
  209. expect((bashCall as { _meta?: unknown })._meta).toBeUndefined()
  210. }, 180_000)
  211. it('with the terminal_output capability, a real bash call renders as a terminal card (content + _meta + exit)', async () => {
  212. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  213. spawned = spawnAcpAgent(workdir)
  214. const { client, updates } = spawned
  215. // Advertise the Zed `_meta.terminal_output` capability so the bridge emits
  216. // the terminal card for the real bash tool.
  217. await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
  218. const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
  219. const res = await client.prompt({
  220. sessionId,
  221. prompt: [{ type: 'text', text: 'Use the bash tool to run: echo ACP_TERMINAL_OK. Then stop.' }],
  222. })
  223. expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
  224. // A bash tool_call now carries a terminal content block + _meta.terminal_info
  225. // with the session cwd as the header; the matching update streams the output
  226. // on _meta.terminal_output.
  227. const bashCall = updates.find(u => u.sessionUpdate === 'tool_call' && u.kind === 'execute')
  228. if (bashCall?.sessionUpdate !== 'tool_call') throw new Error('expected an execute tool_call')
  229. // The content carries the description text block AND a terminal block (the
  230. // description renders above the card) — find the terminal block by type, not
  231. // by position.
  232. const blocks = (bashCall.content ?? []) as { type: string; terminalId?: string }[]
  233. const terminalBlock = blocks.find(b => b.type === 'terminal')
  234. expect(terminalBlock).toBeDefined()
  235. expect(typeof terminalBlock?.terminalId).toBe('string')
  236. const info = (bashCall._meta as { terminal_info?: { terminal_id: string; cwd?: string } }).terminal_info
  237. expect(info?.cwd).toBe(workdir)
  238. const updatesForTerminal = updates.filter(u => u.sessionUpdate === 'tool_call_update' && (u._meta as { terminal_output?: unknown } | undefined)?.terminal_output !== undefined)
  239. expect(updatesForTerminal.length).toBeGreaterThan(0)
  240. // The completed update also carries the parsed exit on _meta.terminal_exit.
  241. const exitUpdate = updates.find(u => u.sessionUpdate === 'tool_call_update' && (u._meta as { terminal_exit?: unknown } | undefined)?.terminal_exit !== undefined)
  242. expect(exitUpdate).toBeDefined()
  243. }, 180_000)
  244. })