app-boot.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  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,
  9. FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
  10. installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
  11. } from '../src/index.ts'
  12. const NAME = 'dsh-test-bin'
  13. const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
  14. describe('resolveConfigPath', () => {
  15. it('resolves relative to the given cwd outside replay mode', () => {
  16. expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml'))
  17. expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml'))
  18. })
  19. it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => {
  20. expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml'))
  21. expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml'))
  22. })
  23. it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => {
  24. expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml'))
  25. expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml'))
  26. })
  27. })
  28. describe('loadEnv', () => {
  29. it('loads variables from .env in the given dir', () => {
  30. const dir = tmp()
  31. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
  32. const warn = vi.fn()
  33. loadEnv(NAME, dir, warn)
  34. expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
  35. expect(warn).not.toHaveBeenCalled()
  36. delete process.env['DSH_APP_BOOT_SPEC_VAR']
  37. })
  38. it('stays silent when no .env exists (ambient environment wins)', () => {
  39. const warn = vi.fn()
  40. loadEnv(NAME, tmp(), warn)
  41. expect(warn).not.toHaveBeenCalled()
  42. })
  43. it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
  44. const dir = tmp()
  45. mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
  46. const warn = vi.fn()
  47. loadEnv(NAME, dir, warn)
  48. expect(warn).toHaveBeenCalledTimes(1)
  49. expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
  50. })
  51. it('defaults dir to the process cwd and warn to a stderr write', () => {
  52. const dir = tmp()
  53. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
  54. const previous = process.cwd()
  55. process.chdir(dir)
  56. try {
  57. loadEnv(NAME) // happy path: the default warn sink is never invoked
  58. } finally {
  59. process.chdir(previous)
  60. }
  61. expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
  62. delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
  63. // The default warn sink itself: point it at a broken .env with stderr
  64. // spied, so the arrow body runs without polluting the test output.
  65. const broken = tmp()
  66. mkdirSync(join(broken, '.env'))
  67. const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
  68. let written: string[]
  69. try {
  70. loadEnv(NAME, broken)
  71. written = write.mock.calls.map(call => String(call[0]))
  72. } finally {
  73. write.mockRestore()
  74. }
  75. expect(written).toHaveLength(1)
  76. expect(written[0]).toContain(`${NAME}: failed to load .env: `)
  77. })
  78. })
  79. describe('installFailLoud', () => {
  80. function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
  81. const handlers: Array<(err: unknown) => void> = []
  82. const written: string[] = []
  83. const exits: number[] = []
  84. return {
  85. handlers, written, exits,
  86. on: (_event, handler) => { handlers.push(handler) },
  87. off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
  88. stderr: { write: (chunk: string) => { written.push(chunk) } },
  89. exit: (code: number) => { exits.push(code) },
  90. }
  91. }
  92. it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
  93. const proc = fakeProc()
  94. installFailLoud(NAME, proc)
  95. const error = new Error('boom')
  96. proc.handlers[0]!(error)
  97. expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
  98. expect(proc.written[0]).toContain(error.stack)
  99. expect(proc.exits).toEqual([1])
  100. })
  101. // One rejection is reported per install: the first is the diagnosis, so each
  102. // formatting case needs its own handler rather than reusing a latched one.
  103. it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
  104. const plain = fakeProc()
  105. installFailLoud(NAME, plain)
  106. plain.handlers[0]!('plain failure')
  107. expect(plain.written[0]).toContain('plain failure')
  108. expect(plain.exits).toEqual([1])
  109. const stackless = new Error('no stack')
  110. delete (stackless as { stack?: string }).stack
  111. const bare = fakeProc()
  112. installFailLoud(NAME, bare)
  113. bare.handlers[0]!(stackless)
  114. expect(bare.written[0]).toContain('no stack')
  115. expect(bare.exits).toEqual([1])
  116. })
  117. it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
  118. const proc = fakeProc()
  119. const uninstall = installFailLoud(NAME, proc)
  120. expect(proc.handlers).toHaveLength(1)
  121. uninstall()
  122. expect(proc.handlers).toHaveLength(0)
  123. // Default-proc arm: install on the real process, then immediately uninstall
  124. // so the suite leaks no handler and can never exit the runner.
  125. const before = process.listenerCount('unhandledRejection')
  126. const uninstallReal = installFailLoud(NAME)
  127. expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
  128. uninstallReal()
  129. expect(process.listenerCount('unhandledRejection')).toBe(before)
  130. })
  131. it('does not report an activation rejection shared by entries in the boot audit', async () => {
  132. const proc = fakeProc()
  133. installFailLoud(NAME, proc)
  134. const error = new Error('assembled activation failure')
  135. const audit = assertEntriesActivated({
  136. loader: {
  137. entries: () => ['broken-a', 'broken-b'].map(name => ({
  138. options: { name },
  139. fiber: {
  140. state: 3,
  141. inject: {},
  142. ctx: { get: () => undefined },
  143. await: async () => { throw error },
  144. },
  145. })),
  146. },
  147. } as unknown as Context, NAME)
  148. await Promise.resolve()
  149. await Promise.resolve()
  150. proc.handlers[0]!(error)
  151. expect(proc.written).toEqual([])
  152. expect(proc.exits).toEqual([])
  153. await expect(audit).rejects.toThrow('assembled activation failure')
  154. proc.handlers[0]!(error)
  155. expect(proc.exits).toEqual([1])
  156. })
  157. // The Loader mounts entries concurrently, so a terminal-owning surface can
  158. // already hold raw mode when a sibling entry rejects. Exiting without running
  159. // its teardown strands the terminal on the user's shell.
  160. it('awaits the release hook before exiting so the terminal owner can restore it', async () => {
  161. const proc = fakeProc()
  162. const order: string[] = []
  163. installFailLoud(NAME, proc, async () => {
  164. await Promise.resolve()
  165. order.push('released')
  166. })
  167. proc.handlers[0]!(new Error('sibling entry rejected'))
  168. expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
  169. // The release is in flight, so the exit has not committed yet.
  170. expect(proc.exits).toEqual([])
  171. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  172. expect(order).toEqual(['released'])
  173. })
  174. it('still exits when the release hook rejects', async () => {
  175. const proc = fakeProc()
  176. installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed')))
  177. proc.handlers[0]!(new Error('boom'))
  178. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  179. })
  180. it('exits without waiting when a release hook never settles', async () => {
  181. vi.useFakeTimers()
  182. try {
  183. const proc = fakeProc()
  184. installFailLoud(NAME, proc, () => new Promise<void>(() => {}))
  185. proc.handlers[0]!(new Error('boom'))
  186. expect(proc.exits).toEqual([])
  187. await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS)
  188. expect(proc.exits).toEqual([1])
  189. } finally {
  190. vi.useRealTimers()
  191. }
  192. })
  193. // Loader failures arrive in bursts, and teardown's own disposers may reject.
  194. // Only the first rejection is the diagnosis; the handler must stay installed
  195. // so a later one cannot become uncaught and kill the process mid-teardown.
  196. it('reports only the first rejection and keeps handling later ones during the release', async () => {
  197. const proc = fakeProc()
  198. let released = false
  199. installFailLoud(NAME, proc, async () => {
  200. await Promise.resolve()
  201. released = true
  202. })
  203. proc.handlers[0]!(new Error('first rejection'))
  204. proc.handlers[0]!(new Error('second rejection'))
  205. expect(proc.handlers).toHaveLength(1)
  206. expect(proc.written).toHaveLength(1)
  207. expect(proc.written[0]).toContain('first rejection')
  208. await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
  209. expect(released).toBe(true)
  210. })
  211. })
  212. describe('assertEntriesLoaded', () => {
  213. const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
  214. ({ loader: { entries: () => entries } }) as unknown as Context
  215. it('passes when every enabled entry has a fiber', () => {
  216. expect(() => { assertEntriesLoaded(ctxWith([
  217. { fiber: {}, options: { name: 'a' } },
  218. { disabled: true, options: { name: 'off' } },
  219. ]), NAME) }).not.toThrow()
  220. })
  221. it('throws naming every enabled fiber-less entry', () => {
  222. expect(() => { assertEntriesLoaded(ctxWith([
  223. { fiber: {}, options: { name: 'ok' } },
  224. { options: { name: 'broken-a' } },
  225. { options: { name: 'broken-b' } },
  226. ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
  227. })
  228. })
  229. describe('assertEntriesActivated', () => {
  230. interface FakeFiber {
  231. state: number
  232. inject: Record<string, unknown>
  233. ctx: { get(name: string): unknown }
  234. await(): Promise<unknown>
  235. }
  236. const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
  237. loader: { entries: () => entries },
  238. }) as unknown as Context
  239. const fiber = (
  240. state: number,
  241. error?: unknown,
  242. inject: Record<string, unknown> = {},
  243. services: string[] = [],
  244. ): FakeFiber => ({
  245. state,
  246. inject,
  247. ctx: { get: name => services.includes(name) ? {} : undefined },
  248. await: error === undefined ? async () => undefined : async () => { throw error },
  249. })
  250. it('passes active entries and ignores disabled entries', async () => {
  251. let awaitCalls = 0
  252. const active = fiber(2)
  253. active.await = async () => {
  254. awaitCalls++
  255. return undefined
  256. }
  257. const disabled = fiber(3, new Error('disabled failure'))
  258. disabled.await = async () => {
  259. awaitCalls++
  260. throw new Error('disabled failure')
  261. }
  262. await expect(assertEntriesActivated(ctxWith([
  263. { fiber: active, options: { name: 'active' } },
  264. { fiber: disabled, disabled: true, options: { name: 'disabled' } },
  265. ]), NAME)).resolves.toBeUndefined()
  266. expect(awaitCalls).toBe(0)
  267. })
  268. it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
  269. const original = new Error('actual plugin failure')
  270. await expect(assertEntriesActivated(ctxWith([
  271. { fiber: fiber(3, original), options: { name: 'broken-plugin' } },
  272. ]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
  273. })
  274. it('formats stackless and non-Error activation failures', async () => {
  275. const stackless = new Error('stackless failure')
  276. delete (stackless as { stack?: string }).stack
  277. await expect(assertEntriesActivated(ctxWith([
  278. { fiber: fiber(3, stackless), options: { name: 'stackless' } },
  279. { fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
  280. ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
  281. })
  282. it('reports unresolved services for pending entries', async () => {
  283. let awaitCalls = 0
  284. const expected = [
  285. `${NAME}: 3 entries did not activate`,
  286. 'waiting: pending (waiting for services: missingA, missingB)',
  287. 'single-wait: pending (waiting for service: missing)',
  288. 'unknown-wait: pending (waiting for services: unknown)',
  289. ].join('\n')
  290. const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
  291. const singleWait = fiber(0, undefined, { missing: {} })
  292. const unknownWait = fiber(0)
  293. for (const item of [waiting, singleWait, unknownWait]) {
  294. item.await = async () => {
  295. awaitCalls++
  296. return undefined
  297. }
  298. }
  299. await expect(assertEntriesActivated(ctxWith([
  300. { fiber: waiting, options: { name: 'waiting' } },
  301. { fiber: singleWait, options: { name: 'single-wait' } },
  302. { fiber: unknownWait, options: { name: 'unknown-wait' } },
  303. ]), NAME)).rejects.toThrow(expected)
  304. expect(awaitCalls).toBe(0)
  305. })
  306. it('retains the numeric diagnostic for a settled unexpected state', async () => {
  307. await expect(assertEntriesActivated(ctxWith([
  308. { fiber: fiber(4), options: { name: 'disposed' } },
  309. ]), NAME)).rejects.toThrow('disposed: fiber state 4')
  310. })
  311. })
  312. describe('loadOverlayPatches', () => {
  313. it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
  314. const dir = tmp()
  315. const valid = join(dir, 'valid.yml')
  316. writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
  317. expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
  318. expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
  319. const malformed = join(dir, 'malformed.yml')
  320. writeFileSync(malformed, ': bad')
  321. expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
  322. const mapping = join(dir, 'mapping.yml')
  323. writeFileSync(mapping, 'id: target\n')
  324. expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
  325. const scalar = join(dir, 'scalar.yml')
  326. writeFileSync(scalar, '- scalar\n')
  327. expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
  328. })
  329. })
  330. describe('boot', () => {
  331. it('boots a leaf config through the real Loader and settles the tree', async () => {
  332. const dir = tmp()
  333. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  334. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  335. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  336. try {
  337. const entries = [...ctx.loader.entries()]
  338. expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
  339. } finally {
  340. await ctx.fiber.dispose()
  341. }
  342. })
  343. it('runs host preparation before the Loader tree mounts', async () => {
  344. const dir = tmp()
  345. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  346. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  347. const prepared: Context[] = []
  348. const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
  349. expect(hostCtx.loader).toBeDefined()
  350. expect([...hostCtx.loader.entries()]).toEqual([])
  351. prepared.push(hostCtx)
  352. })
  353. try {
  354. expect(prepared).toEqual([ctx])
  355. } finally {
  356. await ctx.fiber.dispose()
  357. }
  358. })
  359. it('disposes partial host setup and labels non-Error preparation failures', async () => {
  360. const dir = tmp()
  361. const failure = 42
  362. let disposed = false
  363. const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
  364. ctx.effect(() => () => { disposed = true })
  365. throw failure
  366. })
  367. await expect(task).rejects.toMatchObject({
  368. message: `${NAME}: host preparation failed: ${failure}`,
  369. cause: failure,
  370. })
  371. expect(disposed).toBe(true)
  372. })
  373. it('exposes dshHomePath to Loader config expressions', async () => {
  374. const dir = tmp()
  375. const dshHome = join(dir, 'home')
  376. vi.stubEnv('DSH_HOME', dshHome)
  377. writeFileSync(join(dir, 'capture.mjs'), [
  378. 'export const name = "capture"',
  379. 'export function apply(ctx, config) {',
  380. ' ctx.provide("capturedPath", config.path)',
  381. '}',
  382. '',
  383. ].join('\n'))
  384. writeFileSync(join(dir, 'cordis.yml'), [
  385. '- id: capture',
  386. ' name: ./capture.mjs',
  387. ' config:',
  388. " path: !!js dshHomePath('sessions')",
  389. '',
  390. ].join('\n'))
  391. let ctx: Context | undefined
  392. try {
  393. ctx = await boot(NAME, join(dir, 'cordis.yml'))
  394. expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions'))
  395. } finally {
  396. await ctx?.fiber.dispose()
  397. vi.unstubAllEnvs()
  398. }
  399. })
  400. it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
  401. // A surface can dispose the root fiber while boot() is still awaiting the
  402. // Loader, before the last entry settles. The Loader service goes with the
  403. // tree, so reading it for the post-boot assertions would crash an app that
  404. // exited exactly as the user asked.
  405. const dir = tmp()
  406. writeFileSync(join(dir, 'exiting.mjs'), [
  407. 'export const name = "exiting"',
  408. 'export function apply(ctx) {',
  409. ' void ctx.root.fiber.dispose()',
  410. '}',
  411. '',
  412. ].join('\n'))
  413. writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n')
  414. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  415. expect(ctx.get('loader')).toBeUndefined()
  416. })
  417. it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
  418. const dir = tmp()
  419. writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
  420. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
  421. `${NAME}: plugin tree failed to load: failed to apply loader entry`,
  422. )
  423. })
  424. it('appends the deepest cause with its original stack to the load failure', async () => {
  425. const dir = tmp()
  426. writeFileSync(join(dir, 'failing.mjs'), [
  427. 'export function apply() {',
  428. " const failure = new Error('pinned activation failure')",
  429. " failure.stack = 'Error: pinned activation failure\\n at failing-fixture'",
  430. ' throw failure',
  431. '}',
  432. '',
  433. ].join('\n'))
  434. writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n')
  435. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
  436. String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
  437. String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
  438. ].join('')))
  439. })
  440. it('falls back to the deepest cause message when its stack was erased', async () => {
  441. const dir = tmp()
  442. const deepest = new Error('stackless deep failure')
  443. delete (deepest as { stack?: string }).stack
  444. await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
  445. throw new Error('wrapped setup failure', { cause: deepest })
  446. })).rejects.toThrow(
  447. `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`,
  448. )
  449. })
  450. it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
  451. const dir = tmp()
  452. writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
  453. writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
  454. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
  455. `${NAME}: 1 entry did not activate`,
  456. './waiting.mjs: pending (waiting for service: neverProvided)',
  457. ].join('\n'))
  458. })
  459. })
  460. describe('addHarnessSourceSection', () => {
  461. const SOURCE_ROOT = `${sep}opt${sep}harness-src`
  462. const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`
  463. it('distinguishes the source path from the current workdir between identity and persona', async () => {
  464. const ctx = new Context()
  465. try {
  466. await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
  467. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)
  468. expect(dispose).toBeTypeOf('function')
  469. const systemPrompt = ctx.get('systemPrompt')!
  470. const rendered = renderPrompt(await systemPrompt.assemble())
  471. expect(rendered).toContain(EXPECTED)
  472. // Harness-owned opener (-100) → source (-99) → persona (0). The >= 0 guards
  473. // keep a drifted opener/persona string from a false pass through `-1 < n`.
  474. const identityAt = rendered.indexOf('You are an AI agent powered by the DeepSeek Harness SDK.')
  475. const sourceAt = rendered.indexOf(EXPECTED)
  476. const personaAt = rendered.indexOf('You are a coding agent.')
  477. expect(identityAt).toBeGreaterThanOrEqual(0)
  478. expect(personaAt).toBeGreaterThanOrEqual(0)
  479. expect(identityAt).toBeLessThan(sourceAt)
  480. expect(sourceAt).toBeLessThan(personaAt)
  481. } finally {
  482. await ctx.fiber.dispose()
  483. }
  484. })
  485. it('is a no-op returning undefined when no systemPrompt service is mounted', async () => {
  486. const ctx = new Context()
  487. try {
  488. expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined()
  489. } finally {
  490. await ctx.fiber.dispose()
  491. }
  492. })
  493. it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => {
  494. const ctx = new Context()
  495. try {
  496. await ctx.plugin(SystemPrompt, {})
  497. const systemPrompt = ctx.get('systemPrompt')!
  498. const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)!
  499. const present = await systemPrompt.assemble()
  500. expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true)
  501. dispose()
  502. const gone = await systemPrompt.assemble()
  503. expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false)
  504. } finally {
  505. await ctx.fiber.dispose()
  506. }
  507. })
  508. })