control-surface.e2e.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. /** Generic keyless ACP v1 automation-control conformance over the real dsh profile. */
  2. import { mkdtemp, rm } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  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 { describe, expect, it } from 'vitest'
  13. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  14. const agent: AgentUnderTest = {
  15. binScript: join(repoRoot, 'apps/cli/src/bin.ts'),
  16. libBinScript: join(repoRoot, 'apps/cli/lib/bin.js'),
  17. configPath: fileURLToPath(new URL('./fixtures/control-surface/cordis.yml', import.meta.url)),
  18. profile: 'acp',
  19. tsconfigPath: join(repoRoot, 'tsconfig.json'),
  20. }
  21. const mcpServer = fileURLToPath(new URL('../../../packages/mcp/mcp-client/tests/fixture-server.ts', import.meta.url))
  22. /** Find one named select value in grouped or ungrouped standard options. */
  23. function selectValue(
  24. options: Awaited<ReturnType<LaunchedAcpTestAgent['client']['newSession']>>['configOptions'],
  25. configId: string,
  26. name: string,
  27. ): string {
  28. const option = options?.find(candidate => candidate.id === configId)
  29. if (option?.type !== 'select') throw new Error(`missing select option: ${configId}`)
  30. const values = option.options.flatMap(candidate => 'group' in candidate ? candidate.options : [candidate])
  31. const selected = values.find(candidate => candidate.name === name)
  32. if (selected === undefined) throw new Error(`missing ${configId} value: ${name}`)
  33. return selected.value
  34. }
  35. describe('standard ACP v1 control surface', () => {
  36. it('selects, mounts MCP, closes, restarts, resumes, and cancels through the SDK only', async () => {
  37. const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-control-'))
  38. const persistenceRoot = join(cwd, '.sessions')
  39. const env = { DSH_CONFORMANCE_PERSISTENCE_ROOT: persistenceRoot, DSH_TELEMETRY_DISABLED: '1' }
  40. const mcpServers = [{ name: 'fixture', command: process.execPath, args: [mcpServer], env: [] }]
  41. let first: LaunchedAcpTestAgent | undefined
  42. let second: LaunchedAcpTestAgent | undefined
  43. try {
  44. first = launchAcpTestAgent({ agent, cwd, env })
  45. await first.spawned
  46. const initialized = await first.client.initialize({
  47. protocolVersion: PROTOCOL_VERSION,
  48. clientCapabilities: { _meta: { ignored: true } },
  49. })
  50. expect(initialized.agentCapabilities).toEqual({
  51. mcpCapabilities: { http: true },
  52. promptCapabilities: { image: false, audio: false, embeddedContext: false },
  53. sessionCapabilities: { close: {}, list: {}, resume: {} },
  54. })
  55. expect('_meta' in initialized).toBe(false)
  56. const created = await first.client.newSession({ cwd, mcpServers })
  57. const beta = selectValue(created.configOptions, 'model', 'Beta')
  58. const selectedModel = await first.client.setSessionConfigOption({
  59. sessionId: created.sessionId,
  60. configId: 'model',
  61. value: beta,
  62. })
  63. const low = selectValue(selectedModel.configOptions, 'reasoning_effort', 'Low')
  64. await first.client.setSessionConfigOption({
  65. sessionId: created.sessionId,
  66. configId: 'reasoning_effort',
  67. value: low,
  68. })
  69. await expect(first.client.prompt({
  70. sessionId: created.sessionId,
  71. prompt: [{ type: 'text', text: 'exercise the attached server' }],
  72. })).resolves.toEqual({ stopReason: 'end_turn' })
  73. expect(first.updates.map(update => update.sessionUpdate)).toEqual([
  74. 'agent_thought_chunk',
  75. 'usage_update',
  76. 'tool_call',
  77. 'tool_call_update',
  78. 'agent_message_chunk',
  79. 'usage_update',
  80. ])
  81. expect(first.updates).toContainEqual(expect.objectContaining({
  82. sessionUpdate: 'agent_message_chunk',
  83. content: { type: 'text', text: 'model=beta; tool=5' },
  84. }))
  85. const message = first.updates.find(update => update.sessionUpdate === 'agent_message_chunk')
  86. expect(message !== undefined && 'messageId' in message && typeof message.messageId === 'string').toBe(true)
  87. await first.client.closeSession({ sessionId: created.sessionId })
  88. await first.close()
  89. first = undefined
  90. second = launchAcpTestAgent({ agent, cwd, env })
  91. await second.spawned
  92. await second.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  93. await expect(second.client.listSessions({ cwd })).resolves.toEqual({
  94. sessions: [{ sessionId: created.sessionId, cwd }],
  95. })
  96. await second.client.resumeSession({ sessionId: created.sessionId, cwd, mcpServers })
  97. const toolFinished = second.waitForUpdate(update => (
  98. update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'control-cancel-add'
  99. ))
  100. const prompt = second.client.prompt({
  101. sessionId: created.sessionId,
  102. prompt: [{ type: 'text', text: 'cancel after the tool finishes' }],
  103. })
  104. await toolFinished
  105. await second.client.cancel({ sessionId: created.sessionId })
  106. await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
  107. await second.client.closeSession({ sessionId: created.sessionId })
  108. } finally {
  109. await Promise.allSettled([first?.close(), second?.close()].filter((value): value is Promise<void> => value !== undefined))
  110. await rm(cwd, { recursive: true, force: true })
  111. }
  112. }, 30_000)
  113. })