| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462 |
- import { describe, expect, it } from 'vitest'
- import { Context } from 'cordis'
- import SystemPrompt, { AssembleContext, PromptAssembly, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
- /**
- * Every assembly carries the plugin's own built-ins — `harness:identity`
- * (order −100) and `deployment:persona` (order 0, from config). Tests about
- * registry MECHANICS strip them with {@link contributed} to stay focused on
- * their own sections; the built-ins' behavior is pinned by its own describe.
- */
- const BUILT_IN = ['harness:identity', 'deployment:persona']
- const IDENTITY = 'You are an AI agent powered by the DeepSeek Harness SDK.'
- function contributed(assembly: PromptAssembly): PromptAssembly['sections'] {
- return assembly.sections.filter(section => !BUILT_IN.includes(section.name))
- }
- describe('SystemPrompt', () => {
- describe('built-in sections', () => {
- it('registers the harness identity and the configured deployment persona', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' })
- const assembly = await ctx.systemPrompt.assemble()
- expect(assembly.sections.map(s => s.name)).toEqual([
- 'harness:identity',
- 'deployment:persona',
- ])
- expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.`)
- // The names are reserved by the plugin — one owner per section.
- expect(() => ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'imposter' }))
- .toThrow('prompt section "deployment:persona" is already registered')
- })
- it('renders no persona section for a persona-less deployment (empty default)', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(IDENTITY)
- })
- it('tolerates a schema-bypassing direct construction (persona omitted)', async () => {
- // ctx.plugin validates + defaults the config first; a direct construction
- // skips the schema, so the ctor's `?? ''` narrowing is what fires.
- const ctx = new Context()
- const service = new SystemPrompt(ctx, {})
- expect(renderPrompt(await service.assemble())).toBe(IDENTITY)
- })
- })
- it('assembles sections in order with context-resolved text and collected tools', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' })
- ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
- ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
- ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'echo', description: 'echo back', parameters: {} }] }))
- const assembly = await ctx.systemPrompt.assemble()
- expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd'])
- expect(assembly.sections.map(s => s.text)).toEqual([IDENTITY, 'You are DeepSeek Harness SDK.', 'Be precise.', 'cwd: /tmp'])
- expect(assembly.tools).toEqual([{ name: 'echo', description: 'echo back', parameters: {} }])
- expect(assembly.variables).toEqual({})
- expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.\n\nBe precise.\n\ncwd: /tmp`)
- })
- it('resolves section text providers against the assemble context, at each assemble call', async () => {
- // The context is HOW per-agent sections work (the loop passes { agent });
- // this spec stays agent-agnostic and smuggles a marker through a plain field.
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- let calls = 0
- ctx.systemPrompt.section({
- name: 'dynamic',
- order: 0,
- text: (context: AssembleContext) => `call ${++calls} for ${(context as { who?: string }).who ?? 'nobody'}`,
- })
- expect(contributed(await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext))[0]!.text).toBe('call 1 for alice')
- expect(contributed(await ctx.systemPrompt.assemble())[0]!.text).toBe('call 2 for nobody')
- })
- it('removes contributions when the contributing fiber is disposed (HMR safety)', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- const fiber = await ctx.plugin(Object.assign((inner: Context) => {
- inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' })
- inner.systemPrompt.tools(() => ({ schemas: [{ name: 'scoped-tool', description: '', parameters: {} }] }))
- inner.systemPrompt.variable('scoped_var', () => 'v')
- }, { inject: ['systemPrompt'] }))
- const before = await ctx.systemPrompt.assemble()
- expect(contributed(before)).toHaveLength(1)
- expect(before.variables).toEqual({ scoped_var: 'v' })
- await fiber.dispose()
- const assembly = await ctx.systemPrompt.assemble()
- expect(contributed(assembly)).toHaveLength(0)
- // The built-ins belong to the service fiber, so they survive the plugin's disposal.
- expect(assembly.sections.map(s => s.name)).toEqual(BUILT_IN)
- expect(assembly.tools).toHaveLength(0)
- expect(assembly.variables).toEqual({})
- })
- it('rejects a duplicate section name (a double-loaded plugin must fail, not double its text)', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- ctx.systemPrompt.section({ name: 'dup', order: 0, text: 'first' })
- expect(() => ctx.systemPrompt.section({ name: 'dup', order: 1, text: 'second' }))
- .toThrow('prompt section "dup" is already registered')
- // The failed registration leaked nothing; the original stays intact.
- const assembly = await ctx.systemPrompt.assemble()
- expect(contributed(assembly).map(s => s.text)).toEqual(['first'])
- })
- it('rejects a non-finite section order', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: Number.NaN, text: 'x' }))
- .toThrow('order must be a finite number')
- expect(contributed(await ctx.systemPrompt.assemble())).toEqual([])
- })
- it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- // Throw on the first emit only. Note the rollback path itself emits
- // system-prompt/change, so a multi-shot guard would also fire on rollback;
- // a single-shot guard isolates the register's own emit.
- let threw = false
- const off = ctx.on('system-prompt/change', () => {
- if (!threw) { threw = true; throw new Error('boom change listener') }
- })
- expect(() => ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })).toThrow('boom change listener')
- expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0) // nothing leaked
- // Subsequent listener-free register contributes exactly once.
- off()
- ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })
- expect(contributed(await ctx.systemPrompt.assemble()).map(s => s.name)).toEqual(['p'])
- })
- it('rolls back a tool provider when a system-prompt/change listener throws (P1-1)', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- let threw = false
- const off = ctx.on('system-prompt/change', () => {
- if (!threw) { threw = true; throw new Error('boom change listener') }
- })
- expect(() => ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] }))).toThrow('boom change listener')
- expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) // nothing leaked
- off()
- ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] }))
- expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
- })
- it('snapshots tool-provider membership before evaluating an assembly', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- let added = false
- ctx.systemPrompt.tools(() => {
- if (!added) {
- added = true
- ctx.systemPrompt.tools(() => ({
- schemas: [{ name: 'late', description: '', parameters: {} }],
- }))
- }
- return { schemas: [{ name: 'first', description: '', parameters: {} }] }
- })
- expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first'])
- expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first', 'late'])
- })
- it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- let threw = false
- const off = ctx.on('system-prompt/change', () => {
- if (!threw) { threw = true; throw new Error('boom change listener') }
- })
- expect(() => ctx.systemPrompt.variable('v', () => 'x')).toThrow('boom change listener')
- expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) // nothing leaked
- off()
- ctx.systemPrompt.variable('v', () => 'x')
- expect((await ctx.systemPrompt.assemble()).variables).toEqual({ v: 'x' })
- })
- it('composes multiple system-prompt/assemble waterfall listeners in order, with the context', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' })
- // Listener A appends a section, then delegates.
- const contexts: AssembleContext[] = []
- ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, context, next) => {
- contexts.push(context)
- assembly.sections.push({ name: 'from-a', text: 'a' })
- return next()
- })
- // Listener B (registered later, runs after A) sees A's contribution.
- const seen: string[][] = []
- ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => {
- seen.push(assembly.sections.map(s => s.name))
- return next()
- })
- const passed: AssembleContext = {}
- const assembly = await ctx.systemPrompt.assemble(passed)
- expect(seen).toEqual([['harness:identity', 'deployment:persona', 'base', 'from-a']])
- expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'base', 'from-a'])
- expect(contexts[0]).toBe(passed) // the caller's context reaches listeners
- })
- it('lets a waterfall listener short-circuit by not calling next()', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- ctx.systemPrompt.section({ name: 'real', order: 0, text: 'real' })
- ctx.on('system-prompt/assemble', async () => {
- return { sections: [], tools: [], variables: {} } satisfies PromptAssembly
- })
- const assembly = await ctx.systemPrompt.assemble()
- expect(assembly.sections).toHaveLength(0)
- })
- it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' })
- ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }] }))
- const first = await ctx.systemPrompt.assemble()
- first.sections[0]!.name = 'mutated'
- first.sections[0]!.text = 'mutated'
- first.tools[0]!.description = 'mutated'
- const firstParameters = first.tools[0]!.parameters as { properties: Record<string, unknown> }
- firstParameters.properties['leak'] = { type: 'string' }
- const second = await ctx.systemPrompt.assemble()
- expect(second.sections.map(section => section.name)).toEqual(['harness:identity', 'deployment:persona', 'base'])
- expect(second.sections[0]!.text).toBe(IDENTITY)
- expect(second.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
- })
- it('filters out empty section text from renderPrompt', () => {
- const result = renderPrompt({
- sections: [
- { name: 'empty', text: '' },
- { name: 'real', text: 'content' },
- ],
- tools: [],
- variables: {},
- })
- expect(result).toBe('content')
- })
- it('emits system-prompt/change when a tool provider is registered and disposed', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- let changeCount = 0
- ctx.on('system-prompt/change', () => void changeCount++)
- const dispose = ctx.systemPrompt.tools(() => ({ schemas: [] }))
- // registration emits change
- expect(changeCount).toBe(1)
- dispose()
- // disposal emits change again
- expect(changeCount).toBe(2)
- })
- it('cleans up tool providers on fiber dispose', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- const fiber = await ctx.plugin(Object.assign((inner: Context) => {
- inner.systemPrompt.tools(() => ({ schemas: [{ name: 'fiber-tool', description: '', parameters: {} }] }))
- }, { inject: ['systemPrompt'] }))
- expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
- await fiber.dispose()
- expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0)
- })
- it('removes section when returned disposer is called directly', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- const dispose = ctx.systemPrompt.section({ name: 'direct', order: 0, text: 'direct section' })
- expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(1)
- dispose()
- expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0)
- })
- it('removes tool provider when returned disposer is called directly', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- const dispose = ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'direct-tool', description: '', parameters: {} }] }))
- expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
- dispose()
- expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0)
- })
- describe('prompt variables', () => {
- it('resolves each variable against the assemble context and emits change on register/unregister', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- let changeCount = 0
- ctx.on('system-prompt/change', () => void changeCount++)
- const dispose = ctx.systemPrompt.variable('who', context => (context as { who?: string }).who)
- expect(changeCount).toBe(1)
- expect((await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext)).variables).toEqual({ who: 'alice' })
- // A provider returning undefined records "registered but no value here".
- expect((await ctx.systemPrompt.assemble()).variables).toEqual({ who: undefined })
- dispose()
- expect(changeCount).toBe(2)
- expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
- })
- it('live-iterates variables registered by an earlier provider', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- let added = false
- ctx.systemPrompt.variable('first', () => {
- if (!added) {
- added = true
- ctx.systemPrompt.variable('late', () => 'second value')
- }
- return 'first value'
- })
- expect((await ctx.systemPrompt.assemble()).variables).toEqual({
- first: 'first value',
- late: 'second value',
- })
- })
- it('rejects a duplicate variable name and an unreferenceable name', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- ctx.systemPrompt.variable('model', () => 'm1')
- expect(() => ctx.systemPrompt.variable('model', () => 'm2'))
- .toThrow('prompt variable "model" is already registered')
- expect(() => ctx.systemPrompt.variable('Not Valid', () => 'x'))
- .toThrow('invalid prompt variable name "Not Valid"')
- // Neither failed registration leaked.
- expect((await ctx.systemPrompt.assemble()).variables).toEqual({ model: 'm1' })
- })
- it('interpolates {{name}} references in section text at render — the persona included', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt, { persona: 'You run on {{model}} in {{cwd}}.' })
- ctx.systemPrompt.variable('model', () => 'deepseek-v4')
- ctx.systemPrompt.variable('cwd', () => '/work')
- expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou run on deepseek-v4 in /work.`)
- })
- it('lets a waterfall listener add or override variables before render', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- ctx.systemPrompt.section({ name: 's', order: 0, text: '{{extra}}' })
- ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => {
- assembly.variables['extra'] = 'from-waterfall'
- return next()
- })
- expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nfrom-waterfall`)
- })
- it('throws on a reference to an unregistered variable, listing what exists', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'on {{modle}}' })
- ctx.systemPrompt.variable('model', () => 'm')
- await expect(async () => renderPrompt(await ctx.systemPrompt.assemble()))
- .rejects.toThrow('unknown prompt variable "{{modle}}" in section "persona"; registered variables: model')
- })
- it('names "(none)" when no variables are registered at all', () => {
- expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], tools: [], variables: {} }))
- .toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)')
- })
- it('throws when a referenced variable has no value for this assembly', () => {
- expect(() => renderPrompt({
- sections: [{ name: 'persona', text: 'in {{cwd}}' }],
- tools: [],
- variables: { cwd: undefined },
- })).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")')
- })
- it('throws on a malformed complete reference, e.g. inner spaces', () => {
- expect(() => renderPrompt({
- sections: [{ name: 's', text: 'on {{ model }}' }],
- tools: [],
- variables: { model: 'm' },
- })).toThrow('malformed prompt variable reference "{{ model }}" in section "s"')
- })
- it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => {
- const text = renderPrompt({
- sections: [{ name: 's', text: 'shell ${X:-{{fallback} stays' }],
- tools: [],
- variables: {},
- })
- expect(text).toBe('shell ${X:-{{fallback} stays')
- })
- it.each([
- { text: '{{{model}}}', label: 'extra outer braces' },
- { text: 'x {{a{b}} y {{model}}', label: 'nested brace inside a would-be group' },
- ])('throws on a mangled reference with a }} still following ($label)', ({ text }) => {
- expect(() => renderPrompt({
- sections: [{ name: 's', text }],
- tools: [],
- variables: { model: 'm' },
- })).toThrow('malformed prompt variable reference at')
- })
- it('rejects {{constructor}} as UNKNOWN — prototype properties are not variables', () => {
- // `in` would find Object.prototype.constructor and splice function
- // source into the prompt; Object.hasOwn must reject it instead.
- expect(() => renderPrompt({
- sections: [{ name: 's', text: 'on {{constructor}}' }],
- tools: [],
- variables: { model: 'm' },
- })).toThrow('unknown prompt variable "{{constructor}}"')
- })
- it('a variable NAMED like a prototype property works once actually registered', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- ctx.systemPrompt.section({ name: 's', order: 0, text: '{{constructor}}' })
- ctx.systemPrompt.variable('constructor', () => 'own-value')
- expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nown-value`)
- })
- it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => {
- const text = renderPrompt({
- sections: [{ name: 's', text: 'v = {{model}}!' }],
- tools: [],
- variables: { model: 'literal {{sneaky}} inside' },
- })
- expect(text).toBe('v = literal {{sneaky}} inside!')
- })
- })
- })
|