plan-mode.e2e.ts 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. import { mkdtemp, readFile, rm, writeFile } 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 {
  7. PROTOCOL_VERSION,
  8. type CreateElicitationRequest,
  9. type CreateElicitationResponse,
  10. } from '@agentclientprotocol/sdk'
  11. import {
  12. launchAcpTestAgent,
  13. type AgentUnderTest,
  14. type LaunchedAcpTestAgent,
  15. } from '@deepseek-ai/dsh-acp-snapshot'
  16. /** The shipped ACP leaf's plan mode exercised through its real subprocess entry. */
  17. const AGENT: AgentUnderTest = {
  18. binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
  19. configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
  20. tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
  21. }
  22. let spawned: LaunchedAcpTestAgent | undefined
  23. let workdir: string | undefined
  24. afterEach(async () => {
  25. const ownedSpawned = spawned
  26. const ownedWorkdir = workdir
  27. spawned = undefined
  28. workdir = undefined
  29. try {
  30. if (ownedSpawned !== undefined) {
  31. await ownedSpawned.close('SIGKILL').catch((error: unknown) => {
  32. throw new Error(`plan ACP cleanup failed; child stderr:\n${ownedSpawned.stderr()}`, { cause: error })
  33. })
  34. }
  35. } finally {
  36. if (ownedWorkdir !== undefined) await rm(ownedWorkdir, { recursive: true, force: true })
  37. }
  38. })
  39. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent plan mode e2e: approval gates implementation (real model)', () => {
  40. it('keeps the file unchanged through review, then applies the approved plan', async () => {
  41. workdir = await mkdtemp(join(tmpdir(), 'acp-plan-e2e-'))
  42. const proofPath = join(workdir, 'proof.txt')
  43. await writeFile(proofPath, 'BEFORE\n')
  44. const reviews: CreateElicitationRequest[] = []
  45. let contentAtReview: string | undefined
  46. const createElicitation = async (request: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
  47. if (request.mode !== 'form' || request.requestedSchema.title !== 'Plan review') return { action: 'cancel' }
  48. reviews.push(request)
  49. contentAtReview = await readFile(proofPath, 'utf8')
  50. return { action: 'accept', content: { choice: 'Approve' } }
  51. }
  52. spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir, createElicitation })
  53. const { client, updates } = spawned
  54. const rpc = async <T>(stage: string, operation: Promise<T>): Promise<T> => operation.catch((error: unknown) => {
  55. throw new Error(`plan ACP ${stage} failed; child stderr:\n${spawned?.stderr() ?? '<unavailable>'}`, { cause: error })
  56. })
  57. await rpc('initialize', client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }))
  58. const created = await rpc('session/new', client.newSession({ cwd: workdir, mcpServers: [] }))
  59. expect(created.modes?.availableModes.map(mode => mode.id)).toEqual(['default', 'plan'])
  60. await rpc('session/set_mode', client.setSessionMode({ sessionId: created.sessionId, modeId: 'plan' }))
  61. const result = await rpc('prompt', client.prompt({
  62. sessionId: created.sessionId,
  63. prompt: [{
  64. type: 'text',
  65. text: 'Inspect proof.txt and plan the smallest change that replaces its contents with exactly AFTER followed by one newline. Present the complete plan through exit_plan_mode. After I approve it, implement the change with the filesystem tools, verify the exact file contents, and stop. Do not ask questions.',
  66. }],
  67. }))
  68. expect(['end_turn', 'max_tokens']).toContain(result.stopReason)
  69. expect(reviews).toHaveLength(1)
  70. expect(contentAtReview).toBe('BEFORE\n')
  71. expect(await readFile(proofPath, 'utf8')).toBe('AFTER\n')
  72. expect(updates
  73. .filter(update => update.sessionUpdate === 'current_mode_update')
  74. .map(update => update.currentModeId)).toEqual(['plan', 'default'])
  75. }, 240_000)
  76. })