built-bin.e2e.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { existsSync } from 'node:fs'
  2. import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { dirname, join } from 'node:path'
  5. import { promisify } from 'node:util'
  6. import { fileURLToPath } from 'node:url'
  7. import { zstdDecompress } from 'node:zlib'
  8. import { execa } from 'execa'
  9. import { afterEach, describe, expect, it } from 'vitest'
  10. /**
  11. * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
  12. * The consumer's mock model is an example-local TypeScript plugin (Node 22.19+ — the engines
  13. * floor — strips types natively, so plain `node` loads it), its config carries a `disabled:
  14. * true` unresolvable entry (the fail-loud entry-load guard must not mistake an intentionally
  15. * fiber-less entry for a failed import), and the optional spill pair loads from the consumer
  16. * install — so every passing boot proves all three alongside the CLI's own output contract.
  17. */
  18. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  19. const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js')
  20. const decompress = promisify(zstdDecompress)
  21. const dshPackages = [
  22. 'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session',
  23. 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
  24. 'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'support/invariants', 'ui/app-boot',
  25. 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy',
  26. 'session-persistence/session-persistence-jsonl',
  27. 'context/workspace-context',
  28. 'spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention',
  29. ]
  30. const vendorPackages = ['cordis', 'loader', 'include', 'timer', 'schemastery', 'cosmokit']
  31. async function packageName(dir: string): Promise<string> {
  32. return (JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) as { name: string }).name
  33. }
  34. async function linkPackage(dir: string, nodeModules: string): Promise<void> {
  35. const target = join(nodeModules, await packageName(dir))
  36. await mkdir(dirname(target), { recursive: true })
  37. await symlink(dir, target)
  38. }
  39. async function makeConsumer(): Promise<string> {
  40. const dir = await mkdtemp(join(tmpdir(), 'cli-built-bin-'))
  41. const nodeModules = join(dir, 'node_modules')
  42. for (const rel of dshPackages) await linkPackage(join(repoRoot, 'packages', rel), nodeModules)
  43. for (const rel of vendorPackages) await linkPackage(join(repoRoot, 'vendor', rel), nodeModules)
  44. await writeFile(join(dir, 'mock-llm.ts'), [
  45. // Real type annotations: this file exists to prove plain Node's type
  46. // stripping loads an example-local TS plugin from a built consumer.
  47. "import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'",
  48. "import type { Context } from 'cordis'",
  49. 'class Mock extends LlmAdapter {',
  50. ' async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {',
  51. " const text: string = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''",
  52. " yield { type: 'block-start', index: 0, blockType: 'text' }",
  53. " if (text === 'hang') {",
  54. " yield { type: 'text-delta', index: 0, text: 'partial' }",
  55. ' await new Promise<never>((resolve, reject) => {',
  56. " const timer = setTimeout(() => reject(new Error('hang timeout')), 30000)",
  57. " const onAbort = () => { clearTimeout(timer); reject(new Error('aborted')) }",
  58. ' if (options.signal.aborted) onAbort()',
  59. " else options.signal.addEventListener('abort', onAbort, { once: true })",
  60. ' })',
  61. ' return',
  62. ' }',
  63. ' const reply = `BUILT: ${text}`',
  64. " yield { type: 'text-delta', index: 0, text: reply }",
  65. " yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }",
  66. " yield { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } }",
  67. " yield { type: 'finish', reason: { kind: 'stop' } }",
  68. ' }',
  69. '}',
  70. "export const name = 'built-cli-mock'",
  71. "export const inject = ['llm']",
  72. "export function apply(ctx: Context) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }",
  73. '',
  74. ].join('\n'))
  75. await writeFile(join(dir, 'cordis.yml'), [
  76. '- id: mock-llm',
  77. " name: './mock-llm.ts'",
  78. '- id: subprocess',
  79. " name: '@deepseek-ai/dsh-subprocess-local'",
  80. '- id: bash',
  81. " name: '@deepseek-ai/dsh-bash-local'",
  82. '- id: cli-agent',
  83. " name: '@deepseek-ai/dsh-cli-demo'",
  84. ' config:',
  85. ' provider: built-cli-mock',
  86. ' model: built-cli-mock',
  87. " persona: 'built CLI test'",
  88. " persistenceRoot: './.sessions'",
  89. ' workspaceContext: false',
  90. '- id: spill-local',
  91. " name: '@deepseek-ai/dsh-spill-local'",
  92. '- id: spill-policy',
  93. " name: '@deepseek-ai/dsh-spill-policy'",
  94. ' config:',
  95. ' maxInlineBytes: 50000',
  96. // A `disabled: true` entry settles without a fiber by design; the fail-loud
  97. // entry-load guard must not mistake it for a failed import. The nonexistent
  98. // path makes that distinction observable while a clean run proves boot continued.
  99. '- id: off',
  100. " name: './does-not-exist.ts'",
  101. ' disabled: true',
  102. '',
  103. ].join('\n'))
  104. return dir
  105. }
  106. interface BinResult {
  107. readonly code: number
  108. readonly signal: NodeJS.Signals | null
  109. readonly stdout: string
  110. readonly stderr: string
  111. }
  112. async function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
  113. const subprocess = execa(process.execPath, [cliBin, ...args], {
  114. cwd,
  115. env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
  116. stdin: 'ignore',
  117. timeout: 25_000,
  118. killSignal: 'SIGKILL',
  119. reject: false,
  120. stripFinalNewline: false,
  121. })
  122. // Genuinely custom mid-stream logic: the signal cases deliver `interrupt`
  123. // once the first streamed chunk proves the turn is in flight.
  124. if (interrupt !== undefined) {
  125. let streamed = ''
  126. let interrupted = false
  127. subprocess.stdout.on('data', (chunk: Buffer) => {
  128. streamed += chunk.toString('utf8')
  129. if (!interrupted && streamed.includes('assistant/chunk')) {
  130. interrupted = true
  131. subprocess.kill(interrupt)
  132. }
  133. })
  134. }
  135. const result = await subprocess
  136. if (result.timedOut) {
  137. throw new Error(`built CLI did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  138. }
  139. return { code: result.exitCode ?? -1, signal: result.signal ?? null, stdout: result.stdout, stderr: result.stderr }
  140. }
  141. let consumer: string | undefined
  142. afterEach(async () => {
  143. if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
  144. consumer = undefined
  145. })
  146. describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => {
  147. it('runs text, json, and stream-json under plain Node and persists fresh sessions', async () => {
  148. consumer = await makeConsumer()
  149. const text = await runBuiltBin(consumer, ['--config', './cordis.yml', 'hello'])
  150. expect(text).toMatchObject({ code: 0, signal: null, stdout: 'BUILT: hello\n', stderr: '' })
  151. const json = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'json', 'json task'])
  152. expect(JSON.parse(json.stdout)).toMatchObject({
  153. type: 'result', success: true, result: 'BUILT: json task', reason: { kind: 'completed' },
  154. usage: { inputTokens: 4, outputTokens: 2 },
  155. })
  156. const stream = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'stream-json', 'stream task'])
  157. const lines = stream.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
  158. expect(lines[0]).toMatchObject({ type: 'session_event', event: { type: 'turn/start' } })
  159. expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, result: 'BUILT: stream task' })
  160. const sessionsRoot = join(consumer, '.sessions')
  161. const files = await readdir(sessionsRoot, { recursive: true })
  162. const logs = files.filter(file => file.endsWith('.jsonl.zstd'))
  163. expect(logs).toHaveLength(3)
  164. const compressed = await readFile(join(sessionsRoot, logs[0]!))
  165. expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
  166. expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session' })
  167. }, 30_000)
  168. it('keeps stdout empty for invalid argv and missing config', async () => {
  169. consumer = await makeConsumer()
  170. for (const args of [
  171. ['--config', './cordis.yml'],
  172. ['--config', './cordis.yml', 'one', 'two'],
  173. ['--config', './missing.yml', 'task'],
  174. ]) {
  175. const result = await runBuiltBin(consumer, args)
  176. expect(result.code).not.toBe(0)
  177. expect(result.stdout).toBe('')
  178. expect(result.stderr.length).toBeGreaterThan(0)
  179. }
  180. }, 30_000)
  181. describe.skipIf(process.platform === 'win32')('POSIX signal delivery', () => {
  182. it.each([
  183. ['SIGINT', 130],
  184. ['SIGTERM', 143],
  185. ] as const)('cancels and disposes on %s with exit %i', async (signal, code) => {
  186. consumer = await makeConsumer()
  187. const result = await runBuiltBin(
  188. consumer,
  189. ['--config', './cordis.yml', '--output-format', 'stream-json', 'hang'],
  190. signal,
  191. )
  192. expect(result, JSON.stringify(result)).toMatchObject({ code, signal: null })
  193. expect(result.stdout).toContain('"kind":"aborted"')
  194. expect(result.stderr).toContain('turn 1 was aborted')
  195. }, 30_000)
  196. })
  197. })