load-path.e2e.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
  2. import { Readable, Writable } from 'node:stream'
  3. import { mkdtemp, rm, writeFile } 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. * Source-path Loader smoke through the package's own bin, covering the
  20. * automation server's initialize and fresh-session path across the
  21. * `unwrapExports` shape implicated by postmortem 0001. Session creation reaches
  22. * the factory but not the model, so a dummy key is sufficient.
  23. */
  24. const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
  25. const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
  26. // Repo root is four levels up from packages/examples/acp-demo/tests.
  27. const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
  28. // A minimal opt-in leaf that loads this app + the two backends and the optional
  29. // session-query consumer/policies, inlined so the package test owns its fixture.
  30. const CORDIS_YML = `
  31. - id: llm-deepseek
  32. name: '@deepseek-ai/dsh-llm-deepseek'
  33. config:
  34. apiKey: !!js process.env.DEEPSEEK_API_KEY
  35. - id: bash
  36. name: '@deepseek-ai/dsh-bash-local'
  37. - id: acp-agent
  38. name: '@deepseek-ai/dsh-acp-demo'
  39. config:
  40. provider: deepseek
  41. model: deepseek-v4-flash
  42. persona: 'You are a test agent.'
  43. workspaceContext: false
  44. - id: tool-session-query
  45. name: '@deepseek-ai/dsh-tool-session-query'
  46. - id: timeout-policy
  47. name: '@deepseek-ai/dsh-timeout-policy'
  48. - id: spill-local
  49. name: '@deepseek-ai/dsh-spill-local'
  50. - id: spill-policy
  51. name: '@deepseek-ai/dsh-spill-policy'
  52. config:
  53. maxInlineBytes: 50000
  54. `
  55. interface Spawned {
  56. child: ChildProcessWithoutNullStreams
  57. client: ClientSideConnection
  58. stderr: string[]
  59. }
  60. let spawned: Spawned | undefined
  61. let workdir: string | undefined
  62. afterEach(async () => {
  63. if (spawned !== undefined) {
  64. spawned.child.kill('SIGKILL')
  65. spawned = undefined
  66. }
  67. if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
  68. workdir = undefined
  69. })
  70. async function boot(): Promise<Spawned & { cwd: string }> {
  71. workdir = await mkdtemp(join(tmpdir(), 'acp-agent-pkg-'))
  72. const cwd = workdir
  73. const configPath = join(cwd, 'cordis.yml')
  74. await writeFile(configPath, CORDIS_YML)
  75. const child = spawn(
  76. process.execPath,
  77. ['--import', tsxLoader, binScript, '--config', configPath],
  78. {
  79. cwd,
  80. env: {
  81. ...process.env,
  82. TSX_TSCONFIG_PATH: repoTsconfig,
  83. // Key-present check only; no prompt is sent, so the model is never called.
  84. DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
  85. DSH_HOME: join(cwd, '.dsh'),
  86. DSH_AGENTS_HOME: join(cwd, '.agents'),
  87. },
  88. stdio: ['pipe', 'pipe', 'pipe'],
  89. },
  90. )
  91. const stderr: string[] = []
  92. child.stderr.setEncoding('utf8')
  93. child.stderr.on('data', (chunk: string) => stderr.push(chunk))
  94. const stream = ndJsonStream(
  95. Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  96. Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
  97. )
  98. const makeClient = (_agent: AcpAgent): Client => ({
  99. sessionUpdate(_params: SessionNotification): Promise<void> {
  100. return Promise.resolve()
  101. },
  102. requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
  103. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  104. },
  105. })
  106. const client = new ClientSideConnection(makeClient, stream)
  107. spawned = { child, client, stderr }
  108. return { ...spawned, cwd }
  109. }
  110. describe('dsh-acp-demo real-load-path smoke (bin + Loader, keyless)', () => {
  111. it('boots via its bin and exposes only fresh text sessions', async () => {
  112. const { client, cwd, stderr } = await boot()
  113. // initialize: a broken export shape (collapsed bridge plugin, dropped inject)
  114. // crashes the tree on the first service read here — see postmortem 0001.
  115. const init = await client.initialize({
  116. protocolVersion: PROTOCOL_VERSION,
  117. clientCapabilities: {},
  118. })
  119. expect(init.agentCapabilities).toEqual({
  120. promptCapabilities: { image: false, audio: false, embeddedContext: false },
  121. })
  122. // session/new reaches the agent FACTORY (create) without the model.
  123. const { sessionId } = await client.newSession({ cwd, mcpServers: [] })
  124. expect(sessionId).toBeTruthy()
  125. expect(stderr.join('')).not.toContain('without inject')
  126. }, 30_000)
  127. })