load-path.e2e.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  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. * REAL-load-path smoke for @deepseek-ai/dsh-acp-agent: boot the app through its
  20. * own `bin` (the demo:acp entry) as a subprocess, driving the cordis Loader and
  21. * `unwrapExports` over a minimal `cordis.yml` that loads THIS package. This is
  22. * the guard a hand-built `ctx.plugin({...})` mount structurally cannot be — that
  23. * bypasses `unwrapExports`, the exact path that once dropped the bridge's
  24. * `inject` and shipped (docs/postmortem/0001). It exercises the headline ACP
  25. * operations end-to-end: `initialize` → `session/new` → `session/load`.
  26. *
  27. * KEYLESS: `session/new` and `session/load` reach the agent FACTORY but never
  28. * the model (no prompt is sent), so no DEEPSEEK_API_KEY is needed. A dummy key
  29. * lets `llm-deepseek`'s `apply()` (key-PRESENT check only) boot the tree.
  30. *
  31. * The config is written into a temp dir whose cwd IS the session workspace, so
  32. * the bash workdir validation passes. We point tsx at the repo-root tsconfig
  33. * (TSX_TSCONFIG_PATH) because the child's cwd is outside the repo and the
  34. * unbuilt `paths` map is found by searching UP from cwd.
  35. */
  36. const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
  37. const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
  38. // Repo root is four levels up from packages/ui/acp-agent/tests.
  39. const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
  40. // A minimal leaf that loads this app + the two backends — the same shape as
  41. // examples/acp-agent/cordis.yml, inlined so the package test owns its fixture.
  42. const CORDIS_YML = `
  43. - id: llm-deepseek
  44. name: '@deepseek-ai/dsh-llm-deepseek'
  45. config:
  46. apiKey: !!js process.env.DEEPSEEK_API_KEY
  47. models: [deepseek-v4-flash]
  48. - id: bash
  49. name: '@deepseek-ai/dsh-bash-local'
  50. - id: acp-agent
  51. name: '@deepseek-ai/dsh-acp-agent'
  52. config:
  53. model: deepseek-v4-flash
  54. systemPrompt: 'You are a test agent.'
  55. `
  56. interface Spawned {
  57. child: ChildProcessWithoutNullStreams
  58. client: ClientSideConnection
  59. stderr: string[]
  60. }
  61. let spawned: Spawned | undefined
  62. let workdir: string | undefined
  63. afterEach(async () => {
  64. if (spawned !== undefined) {
  65. spawned.child.kill('SIGKILL')
  66. spawned = undefined
  67. }
  68. if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
  69. workdir = undefined
  70. })
  71. async function boot(): Promise<Spawned & { cwd: string }> {
  72. workdir = await mkdtemp(join(tmpdir(), 'acp-agent-pkg-'))
  73. const cwd = workdir
  74. const configPath = join(cwd, 'cordis.yml')
  75. await writeFile(configPath, CORDIS_YML)
  76. const child = spawn(
  77. process.execPath,
  78. ['--import', tsxLoader, binScript, configPath],
  79. {
  80. cwd,
  81. env: {
  82. ...process.env,
  83. TSX_TSCONFIG_PATH: repoTsconfig,
  84. // Key-present check only; no prompt is sent, so the model is never called.
  85. DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
  86. },
  87. stdio: ['pipe', 'pipe', 'pipe'],
  88. },
  89. )
  90. const stderr: string[] = []
  91. child.stderr.setEncoding('utf8')
  92. child.stderr.on('data', (chunk: string) => stderr.push(chunk))
  93. const stream = ndJsonStream(
  94. Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  95. Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
  96. )
  97. const makeClient = (_agent: AcpAgent): Client => ({
  98. sessionUpdate(_params: SessionNotification): Promise<void> {
  99. return Promise.resolve()
  100. },
  101. requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
  102. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  103. },
  104. })
  105. const client = new ClientSideConnection(makeClient, stream)
  106. spawned = { child, client, stderr }
  107. return { ...spawned, cwd }
  108. }
  109. describe('dsh-acp-agent real-load-path smoke (bin + Loader, keyless)', () => {
  110. it('boots via its bin and answers initialize → session/new → session/load', async () => {
  111. const { client, cwd, stderr } = await boot()
  112. // initialize: a broken export shape (collapsed bridge plugin, dropped inject)
  113. // crashes the tree on the first service read here — see postmortem 0001.
  114. const init = await client.initialize({
  115. protocolVersion: PROTOCOL_VERSION,
  116. clientCapabilities: {},
  117. })
  118. expect(init.agentCapabilities?.loadSession).toBe(true)
  119. // session/new reaches the agent FACTORY (create) without the model.
  120. const { sessionId } = await client.newSession({ cwd, mcpServers: [] })
  121. expect(sessionId).toBeTruthy()
  122. // session/load reaches the resume FACTORY + persistence without the model:
  123. // load an UNKNOWN id (loading the live `sessionId` would correctly reject as
  124. // "already loaded"). The bridge consults `sessionPersistence.list()` then
  125. // `agents.resume()`, both of which run from the JSON-RPC read loop OUTSIDE
  126. // the bridge's inject scope — the exact path postmortem 0001 crashed. A
  127. // healthy tree rejects with a not-found error; a broken export shape would
  128. // instead throw "cannot get property … without inject" before reaching it.
  129. const unknownId = '00000000-0000-4000-8000-000000000000'
  130. await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then(
  131. () => { throw new Error('expected session/load of an unknown id to reject') },
  132. (error: unknown) => { expect(String(error)).not.toContain('without inject') },
  133. )
  134. expect(stderr.join('')).not.toContain('without inject')
  135. }, 30_000)
  136. })