system-prompt.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import SystemPrompt, { AssembleContext, PromptAssembly, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  4. /**
  5. * Every assembly carries the plugin's own built-ins — `harness:identity`
  6. * (order −100) and `deployment:persona` (order 0, from config). Tests about
  7. * registry MECHANICS strip them with {@link contributed} to stay focused on
  8. * their own sections; the built-ins' behavior is pinned by its own describe.
  9. */
  10. const BUILT_IN = ['harness:identity', 'deployment:persona']
  11. const IDENTITY = 'You are an AI agent powered by the DeepSeek Harness SDK.'
  12. function contributed(assembly: PromptAssembly): PromptAssembly['sections'] {
  13. return assembly.sections.filter(section => !BUILT_IN.includes(section.name))
  14. }
  15. describe('SystemPrompt', () => {
  16. describe('built-in sections', () => {
  17. it('registers the harness identity and the configured deployment persona', async () => {
  18. const ctx = new Context()
  19. await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' })
  20. const assembly = await ctx.systemPrompt.assemble()
  21. expect(assembly.sections.map(s => s.name)).toEqual([
  22. 'harness:identity',
  23. 'deployment:persona',
  24. ])
  25. expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.`)
  26. // The names are reserved by the plugin — one owner per section.
  27. expect(() => ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'imposter' }))
  28. .toThrow('prompt section "deployment:persona" is already registered')
  29. })
  30. it('renders no persona section for a persona-less deployment (empty default)', async () => {
  31. const ctx = new Context()
  32. await ctx.plugin(SystemPrompt)
  33. expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(IDENTITY)
  34. })
  35. it('tolerates a schema-bypassing direct construction (persona omitted)', async () => {
  36. // ctx.plugin validates + defaults the config first; a direct construction
  37. // skips the schema, so the ctor's `?? ''` narrowing is what fires.
  38. const ctx = new Context()
  39. const service = new SystemPrompt(ctx, {})
  40. expect(renderPrompt(await service.assemble())).toBe(IDENTITY)
  41. })
  42. })
  43. it('assembles sections in order with context-resolved text and collected tools', async () => {
  44. const ctx = new Context()
  45. await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' })
  46. ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
  47. ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
  48. ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'echo', description: 'echo back', parameters: {} }] }))
  49. const assembly = await ctx.systemPrompt.assemble()
  50. expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd'])
  51. expect(assembly.sections.map(s => s.text)).toEqual([IDENTITY, 'You are DeepSeek Harness SDK.', 'Be precise.', 'cwd: /tmp'])
  52. expect(assembly.tools).toEqual([{ name: 'echo', description: 'echo back', parameters: {} }])
  53. expect(assembly.variables).toEqual({})
  54. expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.\n\nBe precise.\n\ncwd: /tmp`)
  55. })
  56. it('resolves section text providers against the assemble context, at each assemble call', async () => {
  57. // The context is HOW per-agent sections work (the loop passes { agent });
  58. // this spec stays agent-agnostic and smuggles a marker through a plain field.
  59. const ctx = new Context()
  60. await ctx.plugin(SystemPrompt)
  61. let calls = 0
  62. ctx.systemPrompt.section({
  63. name: 'dynamic',
  64. order: 0,
  65. text: (context: AssembleContext) => `call ${++calls} for ${(context as { who?: string }).who ?? 'nobody'}`,
  66. })
  67. expect(contributed(await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext))[0]!.text).toBe('call 1 for alice')
  68. expect(contributed(await ctx.systemPrompt.assemble())[0]!.text).toBe('call 2 for nobody')
  69. })
  70. it('removes contributions when the contributing fiber is disposed (HMR safety)', async () => {
  71. const ctx = new Context()
  72. await ctx.plugin(SystemPrompt)
  73. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  74. inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' })
  75. inner.systemPrompt.tools(() => ({ schemas: [{ name: 'scoped-tool', description: '', parameters: {} }] }))
  76. inner.systemPrompt.variable('scoped_var', () => 'v')
  77. }, { inject: ['systemPrompt'] }))
  78. const before = await ctx.systemPrompt.assemble()
  79. expect(contributed(before)).toHaveLength(1)
  80. expect(before.variables).toEqual({ scoped_var: 'v' })
  81. await fiber.dispose()
  82. const assembly = await ctx.systemPrompt.assemble()
  83. expect(contributed(assembly)).toHaveLength(0)
  84. // The built-ins belong to the service fiber, so they survive the plugin's disposal.
  85. expect(assembly.sections.map(s => s.name)).toEqual(BUILT_IN)
  86. expect(assembly.tools).toHaveLength(0)
  87. expect(assembly.variables).toEqual({})
  88. })
  89. it('rejects a duplicate section name (a double-loaded plugin must fail, not double its text)', async () => {
  90. const ctx = new Context()
  91. await ctx.plugin(SystemPrompt)
  92. ctx.systemPrompt.section({ name: 'dup', order: 0, text: 'first' })
  93. expect(() => ctx.systemPrompt.section({ name: 'dup', order: 1, text: 'second' }))
  94. .toThrow('prompt section "dup" is already registered')
  95. // The failed registration leaked nothing; the original stays intact.
  96. const assembly = await ctx.systemPrompt.assemble()
  97. expect(contributed(assembly).map(s => s.text)).toEqual(['first'])
  98. })
  99. it('rejects a non-finite section order', async () => {
  100. const ctx = new Context()
  101. await ctx.plugin(SystemPrompt)
  102. expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: Number.NaN, text: 'x' }))
  103. .toThrow('order must be a finite number')
  104. expect(contributed(await ctx.systemPrompt.assemble())).toEqual([])
  105. })
  106. it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
  107. const ctx = new Context()
  108. await ctx.plugin(SystemPrompt)
  109. // Throw on the first emit only. Note the rollback path itself emits
  110. // system-prompt/change, so a multi-shot guard would also fire on rollback;
  111. // a single-shot guard isolates the register's own emit.
  112. let threw = false
  113. const off = ctx.on('system-prompt/change', () => {
  114. if (!threw) { threw = true; throw new Error('boom change listener') }
  115. })
  116. expect(() => ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })).toThrow('boom change listener')
  117. expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0) // nothing leaked
  118. // Subsequent listener-free register contributes exactly once.
  119. off()
  120. ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })
  121. expect(contributed(await ctx.systemPrompt.assemble()).map(s => s.name)).toEqual(['p'])
  122. })
  123. it('rolls back a tool provider when a system-prompt/change listener throws (P1-1)', async () => {
  124. const ctx = new Context()
  125. await ctx.plugin(SystemPrompt)
  126. let threw = false
  127. const off = ctx.on('system-prompt/change', () => {
  128. if (!threw) { threw = true; throw new Error('boom change listener') }
  129. })
  130. expect(() => ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] }))).toThrow('boom change listener')
  131. expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) // nothing leaked
  132. off()
  133. ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] }))
  134. expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
  135. })
  136. it('snapshots tool-provider membership before evaluating an assembly', async () => {
  137. const ctx = new Context()
  138. await ctx.plugin(SystemPrompt)
  139. let added = false
  140. ctx.systemPrompt.tools(() => {
  141. if (!added) {
  142. added = true
  143. ctx.systemPrompt.tools(() => ({
  144. schemas: [{ name: 'late', description: '', parameters: {} }],
  145. }))
  146. }
  147. return { schemas: [{ name: 'first', description: '', parameters: {} }] }
  148. })
  149. expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first'])
  150. expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first', 'late'])
  151. })
  152. it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => {
  153. const ctx = new Context()
  154. await ctx.plugin(SystemPrompt)
  155. let threw = false
  156. const off = ctx.on('system-prompt/change', () => {
  157. if (!threw) { threw = true; throw new Error('boom change listener') }
  158. })
  159. expect(() => ctx.systemPrompt.variable('v', () => 'x')).toThrow('boom change listener')
  160. expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) // nothing leaked
  161. off()
  162. ctx.systemPrompt.variable('v', () => 'x')
  163. expect((await ctx.systemPrompt.assemble()).variables).toEqual({ v: 'x' })
  164. })
  165. it('composes multiple system-prompt/assemble waterfall listeners in order, with the context', async () => {
  166. const ctx = new Context()
  167. await ctx.plugin(SystemPrompt)
  168. ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' })
  169. // Listener A appends a section, then delegates.
  170. const contexts: AssembleContext[] = []
  171. ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, context, next) => {
  172. contexts.push(context)
  173. assembly.sections.push({ name: 'from-a', text: 'a' })
  174. return next()
  175. })
  176. // Listener B (registered later, runs after A) sees A's contribution.
  177. const seen: string[][] = []
  178. ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => {
  179. seen.push(assembly.sections.map(s => s.name))
  180. return next()
  181. })
  182. const passed: AssembleContext = {}
  183. const assembly = await ctx.systemPrompt.assemble(passed)
  184. expect(seen).toEqual([['harness:identity', 'deployment:persona', 'base', 'from-a']])
  185. expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'base', 'from-a'])
  186. expect(contexts[0]).toBe(passed) // the caller's context reaches listeners
  187. })
  188. it('lets a waterfall listener short-circuit by not calling next()', async () => {
  189. const ctx = new Context()
  190. await ctx.plugin(SystemPrompt)
  191. ctx.systemPrompt.section({ name: 'real', order: 0, text: 'real' })
  192. ctx.on('system-prompt/assemble', async () => {
  193. return { sections: [], tools: [], variables: {} } satisfies PromptAssembly
  194. })
  195. const assembly = await ctx.systemPrompt.assemble()
  196. expect(assembly.sections).toHaveLength(0)
  197. })
  198. it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
  199. const ctx = new Context()
  200. await ctx.plugin(SystemPrompt)
  201. ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' })
  202. ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }] }))
  203. const first = await ctx.systemPrompt.assemble()
  204. first.sections[0]!.name = 'mutated'
  205. first.sections[0]!.text = 'mutated'
  206. first.tools[0]!.description = 'mutated'
  207. const firstParameters = first.tools[0]!.parameters as { properties: Record<string, unknown> }
  208. firstParameters.properties['leak'] = { type: 'string' }
  209. const second = await ctx.systemPrompt.assemble()
  210. expect(second.sections.map(section => section.name)).toEqual(['harness:identity', 'deployment:persona', 'base'])
  211. expect(second.sections[0]!.text).toBe(IDENTITY)
  212. expect(second.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
  213. })
  214. it('filters out empty section text from renderPrompt', () => {
  215. const result = renderPrompt({
  216. sections: [
  217. { name: 'empty', text: '' },
  218. { name: 'real', text: 'content' },
  219. ],
  220. tools: [],
  221. variables: {},
  222. })
  223. expect(result).toBe('content')
  224. })
  225. it('emits system-prompt/change when a tool provider is registered and disposed', async () => {
  226. const ctx = new Context()
  227. await ctx.plugin(SystemPrompt)
  228. let changeCount = 0
  229. ctx.on('system-prompt/change', () => void changeCount++)
  230. const dispose = ctx.systemPrompt.tools(() => ({ schemas: [] }))
  231. // registration emits change
  232. expect(changeCount).toBe(1)
  233. dispose()
  234. // disposal emits change again
  235. expect(changeCount).toBe(2)
  236. })
  237. it('cleans up tool providers on fiber dispose', async () => {
  238. const ctx = new Context()
  239. await ctx.plugin(SystemPrompt)
  240. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  241. inner.systemPrompt.tools(() => ({ schemas: [{ name: 'fiber-tool', description: '', parameters: {} }] }))
  242. }, { inject: ['systemPrompt'] }))
  243. expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
  244. await fiber.dispose()
  245. expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0)
  246. })
  247. it('removes section when returned disposer is called directly', async () => {
  248. const ctx = new Context()
  249. await ctx.plugin(SystemPrompt)
  250. const dispose = ctx.systemPrompt.section({ name: 'direct', order: 0, text: 'direct section' })
  251. expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(1)
  252. dispose()
  253. expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0)
  254. })
  255. it('removes tool provider when returned disposer is called directly', async () => {
  256. const ctx = new Context()
  257. await ctx.plugin(SystemPrompt)
  258. const dispose = ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'direct-tool', description: '', parameters: {} }] }))
  259. expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
  260. dispose()
  261. expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0)
  262. })
  263. describe('prompt variables', () => {
  264. it('resolves each variable against the assemble context and emits change on register/unregister', async () => {
  265. const ctx = new Context()
  266. await ctx.plugin(SystemPrompt)
  267. let changeCount = 0
  268. ctx.on('system-prompt/change', () => void changeCount++)
  269. const dispose = ctx.systemPrompt.variable('who', context => (context as { who?: string }).who)
  270. expect(changeCount).toBe(1)
  271. expect((await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext)).variables).toEqual({ who: 'alice' })
  272. // A provider returning undefined records "registered but no value here".
  273. expect((await ctx.systemPrompt.assemble()).variables).toEqual({ who: undefined })
  274. dispose()
  275. expect(changeCount).toBe(2)
  276. expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
  277. })
  278. it('live-iterates variables registered by an earlier provider', async () => {
  279. const ctx = new Context()
  280. await ctx.plugin(SystemPrompt)
  281. let added = false
  282. ctx.systemPrompt.variable('first', () => {
  283. if (!added) {
  284. added = true
  285. ctx.systemPrompt.variable('late', () => 'second value')
  286. }
  287. return 'first value'
  288. })
  289. expect((await ctx.systemPrompt.assemble()).variables).toEqual({
  290. first: 'first value',
  291. late: 'second value',
  292. })
  293. })
  294. it('rejects a duplicate variable name and an unreferenceable name', async () => {
  295. const ctx = new Context()
  296. await ctx.plugin(SystemPrompt)
  297. ctx.systemPrompt.variable('model', () => 'm1')
  298. expect(() => ctx.systemPrompt.variable('model', () => 'm2'))
  299. .toThrow('prompt variable "model" is already registered')
  300. expect(() => ctx.systemPrompt.variable('Not Valid', () => 'x'))
  301. .toThrow('invalid prompt variable name "Not Valid"')
  302. // Neither failed registration leaked.
  303. expect((await ctx.systemPrompt.assemble()).variables).toEqual({ model: 'm1' })
  304. })
  305. it('interpolates {{name}} references in section text at render — the persona included', async () => {
  306. const ctx = new Context()
  307. await ctx.plugin(SystemPrompt, { persona: 'You run on {{model}} in {{cwd}}.' })
  308. ctx.systemPrompt.variable('model', () => 'deepseek-v4')
  309. ctx.systemPrompt.variable('cwd', () => '/work')
  310. expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou run on deepseek-v4 in /work.`)
  311. })
  312. it('lets a waterfall listener add or override variables before render', async () => {
  313. const ctx = new Context()
  314. await ctx.plugin(SystemPrompt)
  315. ctx.systemPrompt.section({ name: 's', order: 0, text: '{{extra}}' })
  316. ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => {
  317. assembly.variables['extra'] = 'from-waterfall'
  318. return next()
  319. })
  320. expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nfrom-waterfall`)
  321. })
  322. it('throws on a reference to an unregistered variable, listing what exists', async () => {
  323. const ctx = new Context()
  324. await ctx.plugin(SystemPrompt)
  325. ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'on {{modle}}' })
  326. ctx.systemPrompt.variable('model', () => 'm')
  327. await expect(async () => renderPrompt(await ctx.systemPrompt.assemble()))
  328. .rejects.toThrow('unknown prompt variable "{{modle}}" in section "persona"; registered variables: model')
  329. })
  330. it('names "(none)" when no variables are registered at all', () => {
  331. expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], tools: [], variables: {} }))
  332. .toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)')
  333. })
  334. it('throws when a referenced variable has no value for this assembly', () => {
  335. expect(() => renderPrompt({
  336. sections: [{ name: 'persona', text: 'in {{cwd}}' }],
  337. tools: [],
  338. variables: { cwd: undefined },
  339. })).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")')
  340. })
  341. it('throws on a malformed complete reference, e.g. inner spaces', () => {
  342. expect(() => renderPrompt({
  343. sections: [{ name: 's', text: 'on {{ model }}' }],
  344. tools: [],
  345. variables: { model: 'm' },
  346. })).toThrow('malformed prompt variable reference "{{ model }}" in section "s"')
  347. })
  348. it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => {
  349. const text = renderPrompt({
  350. sections: [{ name: 's', text: 'shell ${X:-{{fallback} stays' }],
  351. tools: [],
  352. variables: {},
  353. })
  354. expect(text).toBe('shell ${X:-{{fallback} stays')
  355. })
  356. it.each([
  357. { text: '{{{model}}}', label: 'extra outer braces' },
  358. { text: 'x {{a{b}} y {{model}}', label: 'nested brace inside a would-be group' },
  359. ])('throws on a mangled reference with a }} still following ($label)', ({ text }) => {
  360. expect(() => renderPrompt({
  361. sections: [{ name: 's', text }],
  362. tools: [],
  363. variables: { model: 'm' },
  364. })).toThrow('malformed prompt variable reference at')
  365. })
  366. it('rejects {{constructor}} as UNKNOWN — prototype properties are not variables', () => {
  367. // `in` would find Object.prototype.constructor and splice function
  368. // source into the prompt; Object.hasOwn must reject it instead.
  369. expect(() => renderPrompt({
  370. sections: [{ name: 's', text: 'on {{constructor}}' }],
  371. tools: [],
  372. variables: { model: 'm' },
  373. })).toThrow('unknown prompt variable "{{constructor}}"')
  374. })
  375. it('a variable NAMED like a prototype property works once actually registered', async () => {
  376. const ctx = new Context()
  377. await ctx.plugin(SystemPrompt)
  378. ctx.systemPrompt.section({ name: 's', order: 0, text: '{{constructor}}' })
  379. ctx.systemPrompt.variable('constructor', () => 'own-value')
  380. expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nown-value`)
  381. })
  382. it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => {
  383. const text = renderPrompt({
  384. sections: [{ name: 's', text: 'v = {{model}}!' }],
  385. tools: [],
  386. variables: { model: 'literal {{sneaky}} inside' },
  387. })
  388. expect(text).toBe('v = literal {{sneaky}} inside!')
  389. })
  390. })
  391. })