app-boot.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  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, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
  9. installFailLoud, loadEnv, loadOverlayPatches, 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('loadEnv', () => {
  28. it('loads variables from .env in the given dir', () => {
  29. const dir = tmp()
  30. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
  31. const warn = vi.fn()
  32. loadEnv(NAME, dir, warn)
  33. expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
  34. expect(warn).not.toHaveBeenCalled()
  35. delete process.env['DSH_APP_BOOT_SPEC_VAR']
  36. })
  37. it('stays silent when no .env exists (ambient environment wins)', () => {
  38. const warn = vi.fn()
  39. loadEnv(NAME, tmp(), warn)
  40. expect(warn).not.toHaveBeenCalled()
  41. })
  42. it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
  43. const dir = tmp()
  44. mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
  45. const warn = vi.fn()
  46. loadEnv(NAME, dir, warn)
  47. expect(warn).toHaveBeenCalledTimes(1)
  48. expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
  49. })
  50. it('defaults dir to the process cwd and warn to a stderr write', () => {
  51. const dir = tmp()
  52. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
  53. const previous = process.cwd()
  54. process.chdir(dir)
  55. try {
  56. loadEnv(NAME) // happy path: the default warn sink is never invoked
  57. } finally {
  58. process.chdir(previous)
  59. }
  60. expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
  61. delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
  62. // The default warn sink itself: point it at a broken .env with stderr
  63. // spied, so the arrow body runs without polluting the test output.
  64. const broken = tmp()
  65. mkdirSync(join(broken, '.env'))
  66. const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
  67. let written: string[]
  68. try {
  69. loadEnv(NAME, broken)
  70. written = write.mock.calls.map(call => String(call[0]))
  71. } finally {
  72. write.mockRestore()
  73. }
  74. expect(written).toHaveLength(1)
  75. expect(written[0]).toContain(`${NAME}: failed to load .env: `)
  76. })
  77. })
  78. describe('installFailLoud', () => {
  79. function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
  80. const handlers: Array<(err: unknown) => void> = []
  81. const written: string[] = []
  82. const exits: number[] = []
  83. return {
  84. handlers, written, exits,
  85. on: (_event, handler) => { handlers.push(handler) },
  86. off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
  87. stderr: { write: (chunk: string) => { written.push(chunk) } },
  88. exit: (code: number) => { exits.push(code) },
  89. }
  90. }
  91. it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
  92. const proc = fakeProc()
  93. installFailLoud(NAME, proc)
  94. const error = new Error('boom')
  95. proc.handlers[0]!(error)
  96. expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
  97. expect(proc.written[0]).toContain(error.stack)
  98. expect(proc.exits).toEqual([1])
  99. })
  100. it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
  101. const proc = fakeProc()
  102. installFailLoud(NAME, proc)
  103. proc.handlers[0]!('plain failure')
  104. expect(proc.written[0]).toContain('plain failure')
  105. const stackless = new Error('no stack')
  106. delete (stackless as { stack?: string }).stack
  107. proc.handlers[0]!(stackless)
  108. expect(proc.written[1]).toContain('no stack')
  109. expect(proc.exits).toEqual([1, 1])
  110. })
  111. it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
  112. const proc = fakeProc()
  113. const uninstall = installFailLoud(NAME, proc)
  114. expect(proc.handlers).toHaveLength(1)
  115. uninstall()
  116. expect(proc.handlers).toHaveLength(0)
  117. // Default-proc arm: install on the real process, then immediately uninstall
  118. // so the suite leaks no handler and can never exit the runner.
  119. const before = process.listenerCount('unhandledRejection')
  120. const uninstallReal = installFailLoud(NAME)
  121. expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
  122. uninstallReal()
  123. expect(process.listenerCount('unhandledRejection')).toBe(before)
  124. })
  125. it('does not report an activation rejection shared by entries in the boot audit', async () => {
  126. const proc = fakeProc()
  127. installFailLoud(NAME, proc)
  128. const error = new Error('assembled activation failure')
  129. const audit = assertEntriesActivated({
  130. loader: {
  131. entries: () => ['broken-a', 'broken-b'].map(name => ({
  132. options: { name },
  133. fiber: {
  134. state: 3,
  135. inject: {},
  136. ctx: { get: () => undefined },
  137. await: async () => { throw error },
  138. },
  139. })),
  140. },
  141. } as unknown as Context, NAME)
  142. await Promise.resolve()
  143. await Promise.resolve()
  144. proc.handlers[0]!(error)
  145. expect(proc.written).toEqual([])
  146. expect(proc.exits).toEqual([])
  147. await expect(audit).rejects.toThrow('assembled activation failure')
  148. proc.handlers[0]!(error)
  149. expect(proc.exits).toEqual([1])
  150. })
  151. })
  152. describe('assertEntriesLoaded', () => {
  153. const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
  154. ({ loader: { entries: () => entries } }) as unknown as Context
  155. it('passes when every enabled entry has a fiber', () => {
  156. expect(() => { assertEntriesLoaded(ctxWith([
  157. { fiber: {}, options: { name: 'a' } },
  158. { disabled: true, options: { name: 'off' } },
  159. ]), NAME) }).not.toThrow()
  160. })
  161. it('throws naming every enabled fiber-less entry', () => {
  162. expect(() => { assertEntriesLoaded(ctxWith([
  163. { fiber: {}, options: { name: 'ok' } },
  164. { options: { name: 'broken-a' } },
  165. { options: { name: 'broken-b' } },
  166. ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
  167. })
  168. })
  169. describe('assertEntriesActivated', () => {
  170. interface FakeFiber {
  171. state: number
  172. inject: Record<string, unknown>
  173. ctx: { get(name: string): unknown }
  174. await(): Promise<unknown>
  175. }
  176. const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
  177. loader: { entries: () => entries },
  178. }) as unknown as Context
  179. const fiber = (
  180. state: number,
  181. error?: unknown,
  182. inject: Record<string, unknown> = {},
  183. services: string[] = [],
  184. ): FakeFiber => ({
  185. state,
  186. inject,
  187. ctx: { get: name => services.includes(name) ? {} : undefined },
  188. await: error === undefined ? async () => undefined : async () => { throw error },
  189. })
  190. it('passes active entries and ignores disabled entries', async () => {
  191. let awaitCalls = 0
  192. const active = fiber(2)
  193. active.await = async () => {
  194. awaitCalls++
  195. return undefined
  196. }
  197. const disabled = fiber(3, new Error('disabled failure'))
  198. disabled.await = async () => {
  199. awaitCalls++
  200. throw new Error('disabled failure')
  201. }
  202. await expect(assertEntriesActivated(ctxWith([
  203. { fiber: active, options: { name: 'active' } },
  204. { fiber: disabled, disabled: true, options: { name: 'disabled' } },
  205. ]), NAME)).resolves.toBeUndefined()
  206. expect(awaitCalls).toBe(0)
  207. })
  208. it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
  209. const original = new Error('actual plugin failure')
  210. await expect(assertEntriesActivated(ctxWith([
  211. { fiber: fiber(3, original), options: { name: 'broken-plugin' } },
  212. ]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
  213. })
  214. it('formats stackless and non-Error activation failures', async () => {
  215. const stackless = new Error('stackless failure')
  216. delete (stackless as { stack?: string }).stack
  217. await expect(assertEntriesActivated(ctxWith([
  218. { fiber: fiber(3, stackless), options: { name: 'stackless' } },
  219. { fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
  220. ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
  221. })
  222. it('reports unresolved services for pending entries', async () => {
  223. let awaitCalls = 0
  224. const expected = [
  225. `${NAME}: 3 entries did not activate`,
  226. 'waiting: pending (waiting for services: missingA, missingB)',
  227. 'single-wait: pending (waiting for service: missing)',
  228. 'unknown-wait: pending (waiting for services: unknown)',
  229. ].join('\n')
  230. const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
  231. const singleWait = fiber(0, undefined, { missing: {} })
  232. const unknownWait = fiber(0)
  233. for (const item of [waiting, singleWait, unknownWait]) {
  234. item.await = async () => {
  235. awaitCalls++
  236. return undefined
  237. }
  238. }
  239. await expect(assertEntriesActivated(ctxWith([
  240. { fiber: waiting, options: { name: 'waiting' } },
  241. { fiber: singleWait, options: { name: 'single-wait' } },
  242. { fiber: unknownWait, options: { name: 'unknown-wait' } },
  243. ]), NAME)).rejects.toThrow(expected)
  244. expect(awaitCalls).toBe(0)
  245. })
  246. it('retains the numeric diagnostic for a settled unexpected state', async () => {
  247. await expect(assertEntriesActivated(ctxWith([
  248. { fiber: fiber(4), options: { name: 'disposed' } },
  249. ]), NAME)).rejects.toThrow('disposed: fiber state 4')
  250. })
  251. })
  252. describe('loadOverlayPatches', () => {
  253. it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
  254. const dir = tmp()
  255. const valid = join(dir, 'valid.yml')
  256. writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
  257. expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
  258. expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
  259. const malformed = join(dir, 'malformed.yml')
  260. writeFileSync(malformed, ': bad')
  261. expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
  262. const mapping = join(dir, 'mapping.yml')
  263. writeFileSync(mapping, 'id: target\n')
  264. expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
  265. const scalar = join(dir, 'scalar.yml')
  266. writeFileSync(scalar, '- scalar\n')
  267. expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
  268. })
  269. })
  270. describe('boot', () => {
  271. it('boots a leaf config through the real Loader and settles the tree', async () => {
  272. const dir = tmp()
  273. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  274. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  275. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  276. try {
  277. const entries = [...ctx.loader.entries()]
  278. expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
  279. } finally {
  280. await ctx.fiber.dispose()
  281. }
  282. })
  283. it('runs host preparation before the Loader tree mounts', async () => {
  284. const dir = tmp()
  285. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  286. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  287. const prepared: Context[] = []
  288. const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
  289. expect(hostCtx.loader).toBeDefined()
  290. expect([...hostCtx.loader.entries()]).toEqual([])
  291. prepared.push(hostCtx)
  292. })
  293. try {
  294. expect(prepared).toEqual([ctx])
  295. } finally {
  296. await ctx.fiber.dispose()
  297. }
  298. })
  299. it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
  300. // What a TUI `/exit` does (ui-tui's disposeRootAndExit): dispose the root
  301. // fiber, which lands while boot() is still awaiting the Loader whenever the
  302. // surface renders before the last entry settles. The Loader service goes
  303. // with the tree, so reading it for the post-boot assertions would crash an
  304. // app that exited exactly as the user asked.
  305. const dir = tmp()
  306. writeFileSync(join(dir, 'exiting.mjs'), [
  307. 'export const name = "exiting"',
  308. 'export function apply(ctx) {',
  309. ' void ctx.root.fiber.dispose()',
  310. '}',
  311. '',
  312. ].join('\n'))
  313. writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n')
  314. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  315. expect(ctx.get('loader')).toBeUndefined()
  316. })
  317. it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
  318. const dir = tmp()
  319. writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
  320. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
  321. })
  322. it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
  323. const dir = tmp()
  324. writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
  325. writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
  326. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
  327. `${NAME}: 1 entry did not activate`,
  328. './waiting.mjs: pending (waiting for service: neverProvided)',
  329. ].join('\n'))
  330. })
  331. })
  332. describe('addHarnessSourceSection', () => {
  333. const SOURCE_ROOT = `${sep}opt${sep}harness-src`
  334. 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.`
  335. it('adds the source path between the harness identity and the deployment persona', async () => {
  336. const ctx = new Context()
  337. try {
  338. await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
  339. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)
  340. expect(dispose).toBeTypeOf('function')
  341. const systemPrompt = ctx.get('systemPrompt')!
  342. const rendered = renderPrompt(await systemPrompt.assemble())
  343. expect(rendered).toContain(EXPECTED)
  344. // Harness-owned opener (-100) → source (-99) → persona (0). The >= 0 guards
  345. // keep a drifted opener/persona string from a false pass through `-1 < n`.
  346. const identityAt = rendered.indexOf('You are an AI agent powered by the DeepSeek Harness SDK.')
  347. const sourceAt = rendered.indexOf(EXPECTED)
  348. const personaAt = rendered.indexOf('You are a coding agent.')
  349. expect(identityAt).toBeGreaterThanOrEqual(0)
  350. expect(personaAt).toBeGreaterThanOrEqual(0)
  351. expect(identityAt).toBeLessThan(sourceAt)
  352. expect(sourceAt).toBeLessThan(personaAt)
  353. } finally {
  354. await ctx.fiber.dispose()
  355. }
  356. })
  357. it('is a no-op returning undefined when no systemPrompt service is mounted', async () => {
  358. const ctx = new Context()
  359. try {
  360. expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined()
  361. } finally {
  362. await ctx.fiber.dispose()
  363. }
  364. })
  365. it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => {
  366. const ctx = new Context()
  367. try {
  368. await ctx.plugin(SystemPrompt, {})
  369. const systemPrompt = ctx.get('systemPrompt')!
  370. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)!
  371. const present = await systemPrompt.assemble()
  372. expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true)
  373. dispose()
  374. const gone = await systemPrompt.assemble()
  375. expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false)
  376. } finally {
  377. await ctx.fiber.dispose()
  378. }
  379. })
  380. })