load-path.e2e.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  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 initialize, session/new, and
  20. * session/load across the `unwrapExports` path implicated by postmortem 0001. Session creation and
  21. * unknown-id loading reach factories but not the model, so a dummy key is sufficient. The temp cwd
  22. * is also the session workspace, and an explicit root tsconfig keeps unbuilt path aliases resolvable
  23. * when the child starts outside the repository.
  24. */
  25. const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
  26. const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
  27. // Repo root is four levels up from packages/examples/acp-demo/tests.
  28. const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
  29. // A minimal leaf that loads this app + the two backends — the same shape as
  30. // examples/acp-agent/cordis.yml, inlined so the package test owns its fixture.
  31. const CORDIS_YML = `
  32. - id: llm-deepseek
  33. name: '@deepseek-ai/dsh-llm-deepseek'
  34. config:
  35. apiKey: !!js process.env.DEEPSEEK_API_KEY
  36. - id: bash
  37. name: '@deepseek-ai/dsh-bash-local'
  38. - id: acp-agent
  39. name: '@deepseek-ai/dsh-acp-demo'
  40. config:
  41. provider: deepseek
  42. model: deepseek-v4-flash
  43. persona: 'You are a test agent.'
  44. workspaceContext: false
  45. `
  46. interface Spawned {
  47. child: ChildProcessWithoutNullStreams
  48. client: ClientSideConnection
  49. stderr: string[]
  50. }
  51. let spawned: Spawned | undefined
  52. let workdir: string | undefined
  53. afterEach(async () => {
  54. if (spawned !== undefined) {
  55. spawned.child.kill('SIGKILL')
  56. spawned = undefined
  57. }
  58. if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
  59. workdir = undefined
  60. })
  61. async function boot(): Promise<Spawned & { cwd: string }> {
  62. workdir = await mkdtemp(join(tmpdir(), 'acp-agent-pkg-'))
  63. const cwd = workdir
  64. const configPath = join(cwd, 'cordis.yml')
  65. await writeFile(configPath, CORDIS_YML)
  66. const child = spawn(
  67. process.execPath,
  68. ['--import', tsxLoader, binScript, '--config', configPath],
  69. {
  70. cwd,
  71. env: {
  72. ...process.env,
  73. TSX_TSCONFIG_PATH: repoTsconfig,
  74. // Key-present check only; no prompt is sent, so the model is never called.
  75. DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
  76. DSH_HOME: join(cwd, '.dsh'),
  77. DSH_AGENTS_HOME: join(cwd, '.agents'),
  78. },
  79. stdio: ['pipe', 'pipe', 'pipe'],
  80. },
  81. )
  82. const stderr: string[] = []
  83. child.stderr.setEncoding('utf8')
  84. child.stderr.on('data', (chunk: string) => stderr.push(chunk))
  85. const stream = ndJsonStream(
  86. Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  87. Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
  88. )
  89. const makeClient = (_agent: AcpAgent): Client => ({
  90. sessionUpdate(_params: SessionNotification): Promise<void> {
  91. return Promise.resolve()
  92. },
  93. requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
  94. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  95. },
  96. })
  97. const client = new ClientSideConnection(makeClient, stream)
  98. spawned = { child, client, stderr }
  99. return { ...spawned, cwd }
  100. }
  101. describe('dsh-acp-demo real-load-path smoke (bin + Loader, keyless)', () => {
  102. it('boots via its bin and answers initialize → session/new → session/load', async () => {
  103. const { client, cwd, stderr } = await boot()
  104. // initialize: a broken export shape (collapsed bridge plugin, dropped inject)
  105. // crashes the tree on the first service read here — see postmortem 0001.
  106. const init = await client.initialize({
  107. protocolVersion: PROTOCOL_VERSION,
  108. clientCapabilities: {},
  109. })
  110. expect(init.agentCapabilities?.loadSession).toBe(true)
  111. // session/new reaches the agent FACTORY (create) without the model.
  112. const { sessionId } = await client.newSession({ cwd, mcpServers: [] })
  113. expect(sessionId).toBeTruthy()
  114. // session/load reaches the resume FACTORY + persistence without the model: load an UNKNOWN
  115. // id (loading the live `sessionId` would correctly reject as "already loaded"). Persistence
  116. // and resume run from the JSON-RPC loop outside bridge injection; a healthy tree reaches
  117. // not-found, while a collapsed export would fail earlier with missing injection.
  118. const unknownId = '00000000-0000-4000-8000-000000000000'
  119. await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then(
  120. () => { throw new Error('expected session/load of an unknown id to reject') },
  121. (error: unknown) => { expect(String(error)).not.toContain('without inject') },
  122. )
  123. expect(stderr.join('')).not.toContain('without inject')
  124. }, 30_000)
  125. })