load-path.e2e.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  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: subprocess
  36. name: '@deepseek-ai/dsh-subprocess-local'
  37. - id: bash
  38. name: '@deepseek-ai/dsh-bash-local'
  39. - id: acp-agent
  40. name: '@deepseek-ai/dsh-acp-demo'
  41. config:
  42. provider: deepseek-official
  43. model: deepseek-v4-flash
  44. persona: 'You are a test agent.'
  45. workspaceContext: false
  46. - id: tool-session-query
  47. name: '@deepseek-ai/dsh-tool-session-query'
  48. - id: timeout-policy
  49. name: '@deepseek-ai/dsh-timeout-policy'
  50. - id: spill-local
  51. name: '@deepseek-ai/dsh-spill-local'
  52. - id: spill-policy
  53. name: '@deepseek-ai/dsh-spill-policy'
  54. config:
  55. maxInlineBytes: 50000
  56. `
  57. interface Spawned {
  58. child: ChildProcessWithoutNullStreams
  59. client: ClientSideConnection
  60. stderr: string[]
  61. }
  62. let spawned: Spawned | undefined
  63. let workdir: string | undefined
  64. afterEach(async () => {
  65. if (spawned !== undefined) {
  66. spawned.child.kill('SIGKILL')
  67. spawned = undefined
  68. }
  69. if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
  70. workdir = undefined
  71. })
  72. async function boot(): Promise<Spawned & { cwd: string }> {
  73. workdir = await mkdtemp(join(tmpdir(), 'acp-agent-pkg-'))
  74. const cwd = workdir
  75. const configPath = join(cwd, 'cordis.yml')
  76. await writeFile(configPath, CORDIS_YML)
  77. const child = spawn(
  78. process.execPath,
  79. ['--import', tsxLoader, binScript, '--config', configPath],
  80. {
  81. cwd,
  82. env: {
  83. ...process.env,
  84. TSX_TSCONFIG_PATH: repoTsconfig,
  85. // Key-present check only; no prompt is sent, so the model is never called.
  86. DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
  87. DSH_HOME: join(cwd, '.dsh'),
  88. DSH_AGENTS_HOME: join(cwd, '.agents'),
  89. },
  90. stdio: ['pipe', 'pipe', 'pipe'],
  91. },
  92. )
  93. const stderr: string[] = []
  94. child.stderr.setEncoding('utf8')
  95. child.stderr.on('data', (chunk: string) => stderr.push(chunk))
  96. const stream = ndJsonStream(
  97. Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  98. Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
  99. )
  100. const makeClient = (_agent: AcpAgent): Client => ({
  101. sessionUpdate(_params: SessionNotification): Promise<void> {
  102. return Promise.resolve()
  103. },
  104. requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
  105. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  106. },
  107. })
  108. const client = new ClientSideConnection(makeClient, stream)
  109. spawned = { child, client, stderr }
  110. return { ...spawned, cwd }
  111. }
  112. describe('dsh-acp-demo real-load-path smoke (bin + Loader, keyless)', () => {
  113. it('boots via its bin and exposes only fresh text sessions', async () => {
  114. const { client, cwd, stderr } = await boot()
  115. // initialize: a broken export shape (collapsed bridge plugin, dropped inject)
  116. // crashes the tree on the first service read here — see postmortem 0001.
  117. const init = await client.initialize({
  118. protocolVersion: PROTOCOL_VERSION,
  119. clientCapabilities: {},
  120. })
  121. expect(init.agentCapabilities).toEqual({
  122. promptCapabilities: { image: false, audio: false, embeddedContext: false },
  123. })
  124. // session/new reaches the agent FACTORY (create) without the model.
  125. const { sessionId } = await client.newSession({ cwd, mcpServers: [] })
  126. expect(sessionId).toBeTruthy()
  127. expect(stderr.join('')).not.toContain('without inject')
  128. }, 30_000)
  129. })