acp.e2e.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import { mkdtemp, readFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
  7. import {
  8. launchAcpTestAgent,
  9. type AgentUnderTest,
  10. type LaunchedAcpTestAgent,
  11. } from '@deepseek-ai/dsh-acp-snapshot'
  12. import { cleanupAcpExampleTest } from './cleanup.ts'
  13. /**
  14. * End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over
  15. * its stdio, drive it with a real ClientSideConnection, send a real prompt, and
  16. * verify the WORLD (a file the agent wrote), not the agent's self-report. Owns
  17. * and disposes the subprocess in afterEach. Key-gated.
  18. *
  19. * Also asserts stdout purity (only framed JSON-RPC on stdout) — that one runs
  20. * WITHOUT a key, since it only needs the server to boot and answer initialize.
  21. */
  22. const AGENT: AgentUnderTest = {
  23. binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
  24. configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
  25. tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
  26. }
  27. const DANGER_FULL_ACCESS_ENV = { DSH_PERMISSION_MODE: 'danger-full-access' }
  28. let spawned: LaunchedAcpTestAgent | undefined
  29. let workdir: string | undefined
  30. afterEach(async () => {
  31. const ownedSpawned = spawned
  32. const ownedWorkdir = workdir
  33. spawned = undefined
  34. workdir = undefined
  35. await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
  36. })
  37. describe('acp-agent over real stdio (no key required)', () => {
  38. it('emits only framed JSON-RPC on stdout', async () => {
  39. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  40. // Inspect the launcher's raw-byte tee in addition to driving its SDK client.
  41. // A dummy key lets the deepseek adapter APPLY (it only checks the key is
  42. // present at boot, not valid — the key is used only on a real model call,
  43. // which this purity test never triggers). So this runs WITHOUT real creds.
  44. spawned = launchAcpTestAgent({
  45. agent: AGENT,
  46. cwd: workdir,
  47. env: {
  48. DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
  49. ...DANGER_FULL_ACCESS_ENV,
  50. },
  51. })
  52. await spawned.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  53. const lines = spawned.rawStdout().split('\n').filter(line => line.trim().length > 0)
  54. expect(lines.length).toBeGreaterThan(0)
  55. for (const line of lines) {
  56. // Every stdout line MUST parse as JSON (a JSON-RPC frame). A non-JSON
  57. // line means a logger/print leaked onto the protocol channel.
  58. expect(() => JSON.parse(line) as unknown).not.toThrow()
  59. }
  60. }, 30_000)
  61. it('session/new succeeds over real stdio (no model call)', async () => {
  62. // REGRESSION GUARD (this exact RPC crashed a real Zed session with
  63. // "cannot get property \"agents\" without inject"): `session/new` drives the
  64. // full bridge → `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop →
  65. // registry/persistence path, ALL of which run from the JSON-RPC read loop
  66. // OUTSIDE the bridge plugin's injection scope. A lazy `ctx.<service>` read
  67. // on that path throws and the RPC fails with an Internal error — yet the
  68. // call never touches the model, so this reproduces WITHOUT a key. The
  69. // key-gated prompt test below never caught it (it needs real creds); the
  70. // initialize-only purity test never caught it (initialize does not reach
  71. // the factory). This closes that gap: boot the real subprocess and create a
  72. // session, asserting the RPC RESOLVES (not rejects with an inject error).
  73. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  74. // A dummy key lets the deepseek adapter boot (it only checks presence, not
  75. // validity, at apply time); no model call is made, so the key is never used.
  76. spawned = launchAcpTestAgent({
  77. agent: AGENT,
  78. cwd: workdir,
  79. env: {
  80. DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
  81. ...DANGER_FULL_ACCESS_ENV,
  82. },
  83. })
  84. const { client } = spawned
  85. await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  86. const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
  87. expect(typeof sessionId).toBe('string')
  88. expect(sessionId.length).toBeGreaterThan(0)
  89. }, 60_000)
  90. })
  91. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over ACP', () => {
  92. it('runs a real turn and the agent writes the requested file (verified on disk)', async () => {
  93. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  94. spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir, env: DANGER_FULL_ACCESS_ENV })
  95. const { client, updates } = spawned
  96. await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  97. // Any absolute cwd is honored now; use the temp `workdir` as this session's
  98. // workspace (the bash tool will run there) — it need not equal the launch dir.
  99. const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
  100. const res = await client.prompt({
  101. sessionId,
  102. 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.' }],
  103. })
  104. expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
  105. // Verify the WORLD, not the agent's self-report: read the file from disk.
  106. const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
  107. expect(proof).toContain('ACP_OK')
  108. // And the client saw tool-call activity stream through.
  109. const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call')
  110. expect(toolCalls.length).toBeGreaterThan(0)
  111. // Tool-call UI quality (the tool owns its presentation): the bash tool's
  112. // `presentCall` sets the title to the exact command (an execute card hides
  113. // rawInput, so the command IS the title) — NOT the bare tool name "bash".
  114. // A `bash` call must therefore carry an execute kind, a non-"bash" title,
  115. // and a string rawInput (the command). `toolCalls` is already narrowed to
  116. // the `tool_call` shape by the filter above, so these fields are reachable.
  117. const bashCall = toolCalls.find(u => u.kind === 'execute')
  118. expect(bashCall).toBeDefined()
  119. if (bashCall === undefined) throw new Error('expected an execute tool_call')
  120. expect(typeof bashCall.title).toBe('string')
  121. expect(bashCall.title.length).toBeGreaterThan(0)
  122. expect(bashCall.title).not.toBe('bash') // the old, unhelpful title
  123. expect(typeof bashCall.rawInput).toBe('string') // the exact command
  124. // Capability OFF: no terminal _meta — the ```console text path renders.
  125. expect((bashCall as { _meta?: unknown })._meta).toBeUndefined()
  126. }, 180_000)
  127. it('with the terminal_output capability, a real bash call renders as a terminal card (content + _meta + exit)', async () => {
  128. workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
  129. spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir, env: DANGER_FULL_ACCESS_ENV })
  130. const { client, updates } = spawned
  131. // Advertise the Zed `_meta.terminal_output` capability so the bridge emits
  132. // the terminal card for the real bash tool.
  133. await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
  134. const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
  135. const res = await client.prompt({
  136. sessionId,
  137. prompt: [{ type: 'text', text: 'Use the bash tool to run: echo ACP_TERMINAL_OK. Then stop.' }],
  138. })
  139. expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
  140. // A bash tool_call now carries a terminal content block + _meta.terminal_info
  141. // with the session cwd as the header; the matching update streams the output
  142. // on _meta.terminal_output.
  143. const bashCall = updates.find(u => u.sessionUpdate === 'tool_call' && u.kind === 'execute')
  144. if (bashCall?.sessionUpdate !== 'tool_call') throw new Error('expected an execute tool_call')
  145. // The content carries the description text block AND a terminal block (the
  146. // description renders above the card) — find the terminal block by type, not
  147. // by position.
  148. const blocks = (bashCall.content ?? []) as { type: string; terminalId?: string }[]
  149. const terminalBlock = blocks.find(b => b.type === 'terminal')
  150. expect(terminalBlock).toBeDefined()
  151. expect(typeof terminalBlock?.terminalId).toBe('string')
  152. const info = (bashCall._meta as { terminal_info?: { terminal_id: string; cwd?: string } }).terminal_info
  153. expect(info?.cwd).toBe(workdir)
  154. const updatesForTerminal = updates.filter(u => u.sessionUpdate === 'tool_call_update' && (u._meta as { terminal_output?: unknown } | undefined)?.terminal_output !== undefined)
  155. expect(updatesForTerminal.length).toBeGreaterThan(0)
  156. // The completed update also carries the parsed exit on _meta.terminal_exit.
  157. const exitUpdate = updates.find(u => u.sessionUpdate === 'tool_call_update' && (u._meta as { terminal_exit?: unknown } | undefined)?.terminal_exit !== undefined)
  158. expect(exitUpdate).toBeDefined()
  159. }, 180_000)
  160. })