built-bin.e2e.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { spawn } from 'node:child_process'
  2. import { existsSync } from 'node:fs'
  3. import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { dirname, join } from 'node:path'
  6. import { promisify } from 'node:util'
  7. import { fileURLToPath } from 'node:url'
  8. import { zstdDecompress } from 'node:zlib'
  9. import { afterEach, describe, expect, it } from 'vitest'
  10. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  11. const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js')
  12. const decompress = promisify(zstdDecompress)
  13. const dshPackages = [
  14. 'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session',
  15. 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
  16. 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
  17. 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl',
  18. 'context/workspace-context',
  19. ]
  20. const vendorPackages = ['cordis', 'loader', 'include', 'timer', 'schemastery', 'cosmokit']
  21. async function packageName(dir: string): Promise<string> {
  22. return (JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) as { name: string }).name
  23. }
  24. async function linkPackage(dir: string, nodeModules: string): Promise<void> {
  25. const target = join(nodeModules, await packageName(dir))
  26. await mkdir(dirname(target), { recursive: true })
  27. await symlink(dir, target)
  28. }
  29. async function makeConsumer(): Promise<string> {
  30. const dir = await mkdtemp(join(tmpdir(), 'cli-built-bin-'))
  31. const nodeModules = join(dir, 'node_modules')
  32. for (const rel of dshPackages) await linkPackage(join(repoRoot, 'packages', rel), nodeModules)
  33. for (const rel of vendorPackages) await linkPackage(join(repoRoot, 'vendor', rel), nodeModules)
  34. await writeFile(join(dir, 'mock-llm.mjs'), [
  35. "import { LlmAdapter } from '@deepseek-ai/dsh-llm'",
  36. 'class Mock extends LlmAdapter {',
  37. ' async * stream(options) {',
  38. " const text = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''",
  39. " yield { type: 'block-start', index: 0, blockType: 'text' }",
  40. " if (text === 'hang') {",
  41. " yield { type: 'text-delta', index: 0, text: 'partial' }",
  42. ' await new Promise((resolve, reject) => {',
  43. " const timer = setTimeout(() => reject(new Error('hang timeout')), 30000)",
  44. " const onAbort = () => { clearTimeout(timer); reject(new Error('aborted')) }",
  45. ' if (options.signal.aborted) onAbort()',
  46. " else options.signal.addEventListener('abort', onAbort, { once: true })",
  47. ' })',
  48. ' return',
  49. ' }',
  50. ' const reply = `BUILT: ${text}`',
  51. " yield { type: 'text-delta', index: 0, text: reply }",
  52. " yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }",
  53. " yield { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } }",
  54. " yield { type: 'finish', reason: { kind: 'stop' } }",
  55. ' }',
  56. '}',
  57. "export const name = 'built-cli-mock'",
  58. "export const inject = ['llm']",
  59. "export function apply(ctx) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }",
  60. '',
  61. ].join('\n'))
  62. await writeFile(join(dir, 'cordis.yml'), [
  63. '- id: mock-llm',
  64. " name: './mock-llm.mjs'",
  65. '- id: bash',
  66. " name: '@deepseek-ai/dsh-bash-local'",
  67. '- id: cli-agent',
  68. " name: '@deepseek-ai/dsh-cli-demo'",
  69. ' config:',
  70. ' provider: built-cli-mock',
  71. ' model: built-cli-mock',
  72. " persona: 'built CLI test'",
  73. " persistenceRoot: './.sessions'",
  74. ' workspaceContext: false',
  75. '',
  76. ].join('\n'))
  77. return dir
  78. }
  79. interface BinResult {
  80. readonly code: number
  81. readonly signal: NodeJS.Signals | null
  82. readonly stdout: string
  83. readonly stderr: string
  84. }
  85. function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
  86. return new Promise((resolveResult, reject) => {
  87. const child = spawn(process.execPath, ['--expose-internals', cliBin, ...args], {
  88. cwd,
  89. env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
  90. stdio: ['ignore', 'pipe', 'pipe'],
  91. })
  92. let stdout = ''
  93. let stderr = ''
  94. let interrupted = false
  95. child.stdout.setEncoding('utf8')
  96. child.stdout.on('data', (chunk: string) => {
  97. stdout += chunk
  98. if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) {
  99. interrupted = true
  100. child.kill(interrupt)
  101. }
  102. })
  103. child.stderr.setEncoding('utf8')
  104. child.stderr.on('data', (chunk: string) => { stderr += chunk })
  105. const timer = setTimeout(() => {
  106. child.kill('SIGKILL')
  107. reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
  108. }, 25_000)
  109. child.once('error', (error) => { clearTimeout(timer); reject(error) })
  110. child.once('exit', (code, signal) => {
  111. clearTimeout(timer)
  112. resolveResult({ code: code ?? -1, signal, stdout, stderr })
  113. })
  114. })
  115. }
  116. let consumer: string | undefined
  117. afterEach(async () => {
  118. if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
  119. consumer = undefined
  120. })
  121. describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => {
  122. it('runs text, json, and stream-json under plain Node and persists fresh sessions', async () => {
  123. consumer = await makeConsumer()
  124. const text = await runBuiltBin(consumer, ['--config', './cordis.yml', 'hello'])
  125. expect(text).toMatchObject({ code: 0, signal: null, stdout: 'BUILT: hello\n', stderr: '' })
  126. const json = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'json', 'json task'])
  127. expect(JSON.parse(json.stdout)).toMatchObject({
  128. type: 'result', success: true, result: 'BUILT: json task', reason: { kind: 'completed' },
  129. usage: { inputTokens: 4, outputTokens: 2 },
  130. })
  131. const stream = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'stream-json', 'stream task'])
  132. const lines = stream.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
  133. expect(lines[0]).toMatchObject({ type: 'session_event', event: { type: 'turn/start' } })
  134. expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, result: 'BUILT: stream task' })
  135. const sessionsRoot = join(consumer, '.sessions')
  136. const files = await readdir(sessionsRoot, { recursive: true })
  137. const logs = files.filter(file => file.endsWith('.jsonl.zstd'))
  138. expect(logs).toHaveLength(3)
  139. const compressed = await readFile(join(sessionsRoot, logs[0]!))
  140. expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
  141. expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session' })
  142. }, 30_000)
  143. it('keeps stdout empty for invalid argv and missing config', async () => {
  144. consumer = await makeConsumer()
  145. for (const args of [
  146. ['--config', './cordis.yml'],
  147. ['--config', './cordis.yml', 'one', 'two'],
  148. ['--config', './missing.yml', 'task'],
  149. ]) {
  150. const result = await runBuiltBin(consumer, args)
  151. expect(result.code).not.toBe(0)
  152. expect(result.stdout).toBe('')
  153. expect(result.stderr.length).toBeGreaterThan(0)
  154. }
  155. }, 30_000)
  156. describe.skipIf(process.platform === 'win32')('POSIX signal delivery', () => {
  157. it.each([
  158. ['SIGINT', 130],
  159. ['SIGTERM', 143],
  160. ] as const)('cancels and disposes on %s with exit %i', async (signal, code) => {
  161. consumer = await makeConsumer()
  162. const result = await runBuiltBin(
  163. consumer,
  164. ['--config', './cordis.yml', '--output-format', 'stream-json', 'hang'],
  165. signal,
  166. )
  167. expect(result, JSON.stringify(result)).toMatchObject({ code, signal: null })
  168. expect(result.stdout).toContain('"kind":"aborted"')
  169. expect(result.stderr).toContain(`received ${signal}`)
  170. }, 30_000)
  171. })
  172. })