app-boot.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join, resolve, sep } from 'node:path'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import { Context } from 'cordis'
  6. import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  7. import {
  8. addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
  9. installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess,
  10. } from '../src/index.ts'
  11. const NAME = 'dsh-test-bin'
  12. const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
  13. describe('resolveConfigPath', () => {
  14. it('resolves relative to the given cwd outside replay mode', () => {
  15. expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml'))
  16. expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml'))
  17. })
  18. it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => {
  19. expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml'))
  20. expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml'))
  21. })
  22. it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => {
  23. expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml'))
  24. expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml'))
  25. })
  26. })
  27. describe('parseResumeArg', () => {
  28. it('returns no resume id and passes arguments through when the flag is absent', () => {
  29. expect(parseResumeArg([])).toEqual({ resumeSessionId: undefined, rest: [] })
  30. expect(parseResumeArg(['custom.yml'])).toEqual({ resumeSessionId: undefined, rest: ['custom.yml'] })
  31. })
  32. it('parses the space form, the inline form, and leaves a positional config path in any position', () => {
  33. expect(parseResumeArg(['--resume', 'sess-1'])).toEqual({ resumeSessionId: 'sess-1', rest: [] })
  34. expect(parseResumeArg(['--resume=sess-2'])).toEqual({ resumeSessionId: 'sess-2', rest: [] })
  35. expect(parseResumeArg(['--resume', 'sess-3', 'app.yml'])).toEqual({ resumeSessionId: 'sess-3', rest: ['app.yml'] })
  36. expect(parseResumeArg(['app.yml', '--resume', 'sess-4'])).toEqual({ resumeSessionId: 'sess-4', rest: ['app.yml'] })
  37. })
  38. it('fails loud on a valueless, empty, or repeated flag rather than silently starting fresh', () => {
  39. expect(() => parseResumeArg(['--resume'])).toThrow('--resume requires a session id')
  40. expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id')
  41. expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once')
  42. })
  43. it('rejects resume syntax used as the flag value instead of resuming a session named like the flag', () => {
  44. expect(() => parseResumeArg(['--resume', '--resume', 'sess'])).toThrow('--resume requires a session id')
  45. expect(() => parseResumeArg(['--resume', '--resume=sess'])).toThrow('--resume requires a session id')
  46. })
  47. })
  48. describe('replaceResumeArg', () => {
  49. it('keeps positional arguments and replaces either existing flag form', () => {
  50. expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
  51. expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
  52. expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next'])
  53. expect(() => replaceResumeArg([], '')).toThrow('non-empty session id')
  54. })
  55. })
  56. describe('loadEnv', () => {
  57. it('loads variables from .env in the given dir', () => {
  58. const dir = tmp()
  59. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
  60. const warn = vi.fn()
  61. loadEnv(NAME, dir, warn)
  62. expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
  63. expect(warn).not.toHaveBeenCalled()
  64. delete process.env['DSH_APP_BOOT_SPEC_VAR']
  65. })
  66. it('stays silent when no .env exists (ambient environment wins)', () => {
  67. const warn = vi.fn()
  68. loadEnv(NAME, tmp(), warn)
  69. expect(warn).not.toHaveBeenCalled()
  70. })
  71. it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
  72. const dir = tmp()
  73. mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
  74. const warn = vi.fn()
  75. loadEnv(NAME, dir, warn)
  76. expect(warn).toHaveBeenCalledTimes(1)
  77. expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
  78. })
  79. it('defaults dir to the process cwd and warn to a stderr write', () => {
  80. const dir = tmp()
  81. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
  82. const previous = process.cwd()
  83. process.chdir(dir)
  84. try {
  85. loadEnv(NAME) // happy path: the default warn sink is never invoked
  86. } finally {
  87. process.chdir(previous)
  88. }
  89. expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
  90. delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
  91. // The default warn sink itself: point it at a broken .env with stderr
  92. // spied, so the arrow body runs without polluting the test output.
  93. const broken = tmp()
  94. mkdirSync(join(broken, '.env'))
  95. const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
  96. let written: string[]
  97. try {
  98. loadEnv(NAME, broken)
  99. written = write.mock.calls.map(call => String(call[0]))
  100. } finally {
  101. write.mockRestore()
  102. }
  103. expect(written).toHaveLength(1)
  104. expect(written[0]).toContain(`${NAME}: failed to load .env: `)
  105. })
  106. })
  107. describe('installFailLoud', () => {
  108. function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
  109. const handlers: Array<(err: unknown) => void> = []
  110. const written: string[] = []
  111. const exits: number[] = []
  112. return {
  113. handlers, written, exits,
  114. on: (_event, handler) => { handlers.push(handler) },
  115. off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
  116. stderr: { write: (chunk: string) => { written.push(chunk) } },
  117. exit: (code: number) => { exits.push(code) },
  118. }
  119. }
  120. it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
  121. const proc = fakeProc()
  122. installFailLoud(NAME, proc)
  123. const error = new Error('boom')
  124. proc.handlers[0]!(error)
  125. expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
  126. expect(proc.written[0]).toContain(error.stack)
  127. expect(proc.exits).toEqual([1])
  128. })
  129. it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
  130. const proc = fakeProc()
  131. installFailLoud(NAME, proc)
  132. proc.handlers[0]!('plain failure')
  133. expect(proc.written[0]).toContain('plain failure')
  134. const stackless = new Error('no stack')
  135. delete (stackless as { stack?: string }).stack
  136. proc.handlers[0]!(stackless)
  137. expect(proc.written[1]).toContain('no stack')
  138. expect(proc.exits).toEqual([1, 1])
  139. })
  140. it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
  141. const proc = fakeProc()
  142. const uninstall = installFailLoud(NAME, proc)
  143. expect(proc.handlers).toHaveLength(1)
  144. uninstall()
  145. expect(proc.handlers).toHaveLength(0)
  146. // Default-proc arm: install on the real process, then immediately uninstall
  147. // so the suite leaks no handler and can never exit the runner.
  148. const before = process.listenerCount('unhandledRejection')
  149. const uninstallReal = installFailLoud(NAME)
  150. expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
  151. uninstallReal()
  152. expect(process.listenerCount('unhandledRejection')).toBe(before)
  153. })
  154. })
  155. describe('assertEntriesLoaded', () => {
  156. const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
  157. ({ loader: { entries: () => entries } }) as unknown as Context
  158. it('passes when every enabled entry has a fiber', () => {
  159. expect(() => { assertEntriesLoaded(ctxWith([
  160. { fiber: {}, options: { name: 'a' } },
  161. { disabled: true, options: { name: 'off' } },
  162. ]), NAME) }).not.toThrow()
  163. })
  164. it('throws naming every enabled fiber-less entry', () => {
  165. expect(() => { assertEntriesLoaded(ctxWith([
  166. { fiber: {}, options: { name: 'ok' } },
  167. { options: { name: 'broken-a' } },
  168. { options: { name: 'broken-b' } },
  169. ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
  170. })
  171. })
  172. describe('boot', () => {
  173. it('boots a leaf config through the real Loader and settles the tree', async () => {
  174. const dir = tmp()
  175. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  176. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  177. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  178. try {
  179. const entries = [...ctx.loader.entries()]
  180. expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
  181. } finally {
  182. await ctx.fiber.dispose()
  183. }
  184. })
  185. it('runs host preparation before the Loader tree mounts', async () => {
  186. const dir = tmp()
  187. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  188. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  189. const prepared: Context[] = []
  190. const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) })
  191. try {
  192. expect(prepared).toEqual([ctx])
  193. } finally {
  194. await ctx.fiber.dispose()
  195. }
  196. })
  197. it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
  198. const dir = tmp()
  199. writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
  200. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
  201. })
  202. })
  203. describe('addHarnessSourceSection', () => {
  204. const SOURCE_ROOT = `${sep}opt${sep}harness-src`
  205. const EXPECTED = `Your own source code is the checkout at ${SOURCE_ROOT}; you can read it there to learn how dsh works and how to extend it.`
  206. it('adds the source path between the harness identity and the deployment persona', async () => {
  207. const ctx = new Context()
  208. try {
  209. await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
  210. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)
  211. expect(dispose).toBeTypeOf('function')
  212. const systemPrompt = ctx.get('systemPrompt')!
  213. const rendered = renderPrompt(await systemPrompt.assemble())
  214. expect(rendered).toContain(EXPECTED)
  215. // Harness-owned opener (-100) → source (-99) → persona (0). The >= 0 guards
  216. // keep a drifted opener/persona string from a false pass through `-1 < n`.
  217. const identityAt = rendered.indexOf('You are an AI agent powered by the DeepSeek Harness SDK.')
  218. const sourceAt = rendered.indexOf(EXPECTED)
  219. const personaAt = rendered.indexOf('You are a coding agent.')
  220. expect(identityAt).toBeGreaterThanOrEqual(0)
  221. expect(personaAt).toBeGreaterThanOrEqual(0)
  222. expect(identityAt).toBeLessThan(sourceAt)
  223. expect(sourceAt).toBeLessThan(personaAt)
  224. } finally {
  225. await ctx.fiber.dispose()
  226. }
  227. })
  228. it('is a no-op returning undefined when no systemPrompt service is mounted', async () => {
  229. const ctx = new Context()
  230. try {
  231. expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined()
  232. } finally {
  233. await ctx.fiber.dispose()
  234. }
  235. })
  236. it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => {
  237. const ctx = new Context()
  238. try {
  239. await ctx.plugin(SystemPrompt, {})
  240. const systemPrompt = ctx.get('systemPrompt')!
  241. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)!
  242. const present = await systemPrompt.assemble()
  243. expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true)
  244. dispose()
  245. const gone = await systemPrompt.assemble()
  246. expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false)
  247. } finally {
  248. await ctx.fiber.dispose()
  249. }
  250. })
  251. })