acp.e2e.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  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. * End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over
  20. * its stdio, drive it with a real ClientSideConnection, send a real prompt, and
  21. * verify the WORLD (a file the agent wrote), not the agent's self-report. Owns
  22. * and disposes the subprocess in afterEach. Key-gated.
  23. *
  24. * Also asserts stdout purity (only framed JSON-RPC on stdout) — that one runs
  25. * WITHOUT a key, since it only needs the server to boot and answer initialize.
  26. */
  27. // The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml. The
  28. // bin resolves its config-path arg from CWD; the subprocess runs from a temp
  29. // workdir, so pass the example config's ABSOLUTE path.
  30. const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url))
  31. const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
  32. // Resolve tsx's loader to an ABSOLUTE path: the subprocess runs with cwd set to
  33. // a temp workdir (this test launches there and uses it as the session cwd; the
  34. // bridge no longer requires cwd === the launch dir, but a temp dir keeps the
  35. // test hermetic), where a bare `--import tsx` would not resolve from
  36. // node_modules. import.meta.resolve gives the worktree's tsx regardless of cwd.
  37. const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
  38. // Absolute path to the repo-root tsconfig. Dev/test/demo run UNBUILT: the
  39. // `@deepseek-ai/dsh-*` workspace imports resolve through the `paths` map in the
  40. // root tsconfig (tsx reads it), NOT through built `lib/` output. But tsx finds
  41. // that tsconfig by searching UP from the child's cwd — and the child's cwd is a
  42. // temp workdir OUTSIDE the repo, so the search misses and the dsh-* imports fail
  43. // (the child dies before writing a byte). Point tsx at the repo tsconfig
  44. // explicitly via TSX_TSCONFIG_PATH so resolution is cwd-independent. (Without
  45. // this the suite only passed by accident when a stale built `lib/` happened to
  46. // exist — exactly the contamination that masked the inject bug this suite now
  47. // guards.) The repo root is four levels up from this file (examples/acp-agent/tests).
  48. const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  49. interface Spawned {
  50. child: ChildProcessWithoutNullStreams
  51. client: ClientSideConnection
  52. updates: SessionNotification['update'][]
  53. stderr: string[]
  54. }
  55. // TODO(acp-test-harness): this subprocess/client boot glue is duplicated with
  56. // hooks.e2e.ts and partly with dsh-acp-snapshot's harness. Migrate both e2e
  57. // files onto that launcher before the TSX/env/permission-stub details drift.
  58. function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned {
  59. const child = spawn(
  60. process.execPath,
  61. ['--import', tsxLoader, binScript, configPath],
  62. {
  63. cwd,
  64. env: {
  65. ...env,
  66. TSX_TSCONFIG_PATH: repoTsconfig,
  67. DSH_HOME: join(cwd, '.dsh'),
  68. DSH_AGENTS_HOME: join(cwd, '.agents'),
  69. },
  70. stdio: ['pipe', 'pipe', 'pipe'],
  71. },
  72. )
  73. const stderr: string[] = []
  74. child.stderr.setEncoding('utf8')
  75. child.stderr.on('data', (chunk: string) => stderr.push(chunk))
  76. const updates: SessionNotification['update'][] = []
  77. const stream = ndJsonStream(
  78. Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  79. Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
  80. )
  81. const makeClient = (_agent: AcpAgent): Client => ({
  82. sessionUpdate(params: SessionNotification): Promise<void> {
  83. updates.push(params.update)
  84. return Promise.resolve()
  85. },
  86. requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
  87. // This example composes no ask-producing policy (no hooks), so the
  88. // bridge never prompts here; answer cancelled (fail closed) if it ever
  89. // does — an unexpected prompt must not grant anything.
  90. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  91. },
  92. })
  93. const client = new ClientSideConnection(makeClient, stream)
  94. return { child, client, updates, stderr }
  95. }
  96. let spawned: Spawned | undefined
  97. let workdir: string | undefined
  98. function hasStdoutLine(out: string[]): boolean {
  99. return out.join('').split('\n').some(line => line.trim().length > 0)
  100. }
  101. async function waitForStdoutLine(child: ChildProcessWithoutNullStreams, out: string[], stderr: string[], timeoutMs: number): Promise<void> {
  102. await new Promise<void>((resolve, reject) => {
  103. const cleanup = () => {
  104. clearTimeout(timeout)
  105. child.stdout.off('data', onData)
  106. child.off('exit', onExit)
  107. child.off('error', onError)
  108. }
  109. const pass = () => {
  110. cleanup()
  111. resolve()
  112. }
  113. const fail = (reason: string) => {
  114. cleanup()
  115. reject(new Error(`${reason}; stderr: ${stderr.join('')}`))
  116. }
  117. const onData = () => {
  118. if (hasStdoutLine(out)) pass()
  119. }
  120. const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
  121. fail(`ACP child exited before emitting a stdout frame (code ${code ?? 'null'}, signal ${signal ?? 'null'})`)
  122. }
  123. const onError = (error: Error) => {
  124. fail(`ACP child failed before emitting a stdout frame: ${error.message}`)
  125. }
  126. const timeout = setTimeout(() => {
  127. fail(`ACP child did not emit a stdout frame within ${timeoutMs}ms`)
  128. }, timeoutMs)
  129. child.stdout.on('data', onData)
  130. child.on('exit', onExit)
  131. child.on('error', onError)
  132. onData()
  133. })
  134. }
  135. afterEach(async () => {
  136. if (spawned) {
  137. spawned.child.kill('SIGKILL')
  138. spawned = undefined
  139. }
  140. if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
  141. workdir = undefined
  142. })
  143. describe('acp-agent over real stdio (no key required)', () => {
  144. it('emits only framed JSON-RPC on stdout', async () => {
  145. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  146. // Collect raw stdout bytes directly (bypass the SDK framing) to inspect.
  147. // A dummy key lets the deepseek adapter APPLY (it only checks the key is
  148. // present at boot, not valid — the key is used only on a real model call,
  149. // which this purity test never triggers). So this runs WITHOUT real creds.
  150. const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], {
  151. cwd: workdir,
  152. env: {
  153. ...process.env,
  154. DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
  155. TSX_TSCONFIG_PATH: repoTsconfig,
  156. DSH_HOME: join(workdir, '.dsh'),
  157. DSH_AGENTS_HOME: join(workdir, '.agents'),
  158. },
  159. stdio: ['pipe', 'pipe', 'pipe'],
  160. })
  161. const out: string[] = []
  162. const stderr: string[] = []
  163. child.stdout.setEncoding('utf8')
  164. child.stderr.setEncoding('utf8')
  165. child.stdout.on('data', (c: string) => out.push(c))
  166. child.stderr.on('data', (c: string) => stderr.push(c))
  167. // Send a single initialize request as a newline-delimited JSON-RPC frame.
  168. const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } })
  169. child.stdin.write(req + '\n')
  170. try {
  171. await waitForStdoutLine(child, out, stderr, 15_000)
  172. } finally {
  173. child.kill('SIGKILL')
  174. }
  175. const lines = out.join('').split('\n').filter(l => l.trim().length > 0)
  176. expect(lines.length).toBeGreaterThan(0)
  177. for (const line of lines) {
  178. // Every stdout line MUST parse as JSON (a JSON-RPC frame). A non-JSON
  179. // line means a logger/print leaked onto the protocol channel.
  180. expect(() => JSON.parse(line) as unknown).not.toThrow()
  181. }
  182. }, 30_000)
  183. it('session/new succeeds over real stdio (no model call)', async () => {
  184. // REGRESSION GUARD (this exact RPC crashed a real Zed session with
  185. // "cannot get property \"agents\" without inject"): `session/new` drives the
  186. // full bridge → `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop →
  187. // registry/persistence path, ALL of which run from the JSON-RPC read loop
  188. // OUTSIDE the bridge plugin's injection scope. A lazy `ctx.<service>` read
  189. // on that path throws and the RPC fails with an Internal error — yet the
  190. // call never touches the model, so this reproduces WITHOUT a key. The
  191. // key-gated prompt test below never caught it (it needs real creds); the
  192. // initialize-only purity test never caught it (initialize does not reach
  193. // the factory). This closes that gap: boot the real subprocess and create a
  194. // session, asserting the RPC RESOLVES (not rejects with an inject error).
  195. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  196. // A dummy key lets the deepseek adapter boot (it only checks presence, not
  197. // validity, at apply time); no model call is made, so the key is never used.
  198. spawned = spawnAcpAgent(workdir, { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' })
  199. const { client } = spawned
  200. await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  201. const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
  202. expect(typeof sessionId).toBe('string')
  203. expect(sessionId.length).toBeGreaterThan(0)
  204. }, 60_000)
  205. })
  206. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over ACP', () => {
  207. it('runs a real turn and the agent writes the requested file (verified on disk)', async () => {
  208. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  209. spawned = spawnAcpAgent(workdir)
  210. const { client, updates } = spawned
  211. await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  212. // Any absolute cwd is honored now; use the temp `workdir` as this session's
  213. // workspace (the bash tool will run there) — it need not equal the launch dir.
  214. const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
  215. const res = await client.prompt({
  216. sessionId,
  217. 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.' }],
  218. })
  219. expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
  220. // Verify the WORLD, not the agent's self-report: read the file from disk.
  221. const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
  222. expect(proof).toContain('ACP_OK')
  223. // And the client saw tool-call activity stream through.
  224. const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call')
  225. expect(toolCalls.length).toBeGreaterThan(0)
  226. // Tool-call UI quality (the tool owns its presentation): the bash tool's
  227. // `presentCall` sets the title to the exact command (an execute card hides
  228. // rawInput, so the command IS the title) — NOT the bare tool name "bash".
  229. // A `bash` call must therefore carry an execute kind, a non-"bash" title,
  230. // and a string rawInput (the command). `toolCalls` is already narrowed to
  231. // the `tool_call` shape by the filter above, so these fields are reachable.
  232. const bashCall = toolCalls.find(u => u.kind === 'execute')
  233. expect(bashCall).toBeDefined()
  234. if (bashCall === undefined) throw new Error('expected an execute tool_call')
  235. expect(typeof bashCall.title).toBe('string')
  236. expect(bashCall.title.length).toBeGreaterThan(0)
  237. expect(bashCall.title).not.toBe('bash') // the old, unhelpful title
  238. expect(typeof bashCall.rawInput).toBe('string') // the exact command
  239. // Capability OFF: no terminal _meta — the ```console text path renders.
  240. expect((bashCall as { _meta?: unknown })._meta).toBeUndefined()
  241. }, 180_000)
  242. it('with the terminal_output capability, a real bash call renders as a terminal card (content + _meta + exit)', async () => {
  243. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  244. spawned = spawnAcpAgent(workdir)
  245. const { client, updates } = spawned
  246. // Advertise the Zed `_meta.terminal_output` capability so the bridge emits
  247. // the terminal card for the real bash tool.
  248. await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
  249. const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
  250. const res = await client.prompt({
  251. sessionId,
  252. prompt: [{ type: 'text', text: 'Use the bash tool to run: echo ACP_TERMINAL_OK. Then stop.' }],
  253. })
  254. expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
  255. // A bash tool_call now carries a terminal content block + _meta.terminal_info
  256. // with the session cwd as the header; the matching update streams the output
  257. // on _meta.terminal_output.
  258. const bashCall = updates.find(u => u.sessionUpdate === 'tool_call' && u.kind === 'execute')
  259. if (bashCall?.sessionUpdate !== 'tool_call') throw new Error('expected an execute tool_call')
  260. // The content carries the description text block AND a terminal block (the
  261. // description renders above the card) — find the terminal block by type, not
  262. // by position.
  263. const blocks = (bashCall.content ?? []) as { type: string; terminalId?: string }[]
  264. const terminalBlock = blocks.find(b => b.type === 'terminal')
  265. expect(terminalBlock).toBeDefined()
  266. expect(typeof terminalBlock?.terminalId).toBe('string')
  267. const info = (bashCall._meta as { terminal_info?: { terminal_id: string; cwd?: string } }).terminal_info
  268. expect(info?.cwd).toBe(workdir)
  269. const updatesForTerminal = updates.filter(u => u.sessionUpdate === 'tool_call_update' && (u._meta as { terminal_output?: unknown } | undefined)?.terminal_output !== undefined)
  270. expect(updatesForTerminal.length).toBeGreaterThan(0)
  271. // The completed update also carries the parsed exit on _meta.terminal_exit.
  272. const exitUpdate = updates.find(u => u.sessionUpdate === 'tool_call_update' && (u._meta as { terminal_exit?: unknown } | undefined)?.terminal_exit !== undefined)
  273. expect(exitUpdate).toBeDefined()
  274. }, 180_000)
  275. })