scoped.spec.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
  4. import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
  5. import SystemPrompt, { TOOL_ORDER_REST, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  6. import type { Config, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
  7. async function mount(config: Config = {}): Promise<Context> {
  8. const ctx = new Context()
  9. await ctx.plugin(SystemPrompt, config)
  10. return ctx
  11. }
  12. async function mintScope(ctx: Context, name: string): Promise<Scope> {
  13. let scope!: Scope
  14. // The scoped context resolves services through the MINTING plugin's
  15. // dependency chain — the minter must inject what scope holders will reach.
  16. await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, { name }) },
  17. { inject: ['systemPrompt'] }))
  18. return scope
  19. }
  20. const schema = (name: string) => ({ name, description: `tool ${name}`, parameters: {} })
  21. /** The key a test scope was minted with (scopeOf over the scope's own ctx). */
  22. function scopeKeyOf(scope: Scope): ScopeKey {
  23. // scopeOf never answers undefined for a context the scope itself minted.
  24. return scopeOf(scope.ctx)!
  25. }
  26. describe('scoped sections', () => {
  27. it('a scoped persona shadows deployment:persona for that scope only (either order)', async () => {
  28. const ctx = await mount({ persona: 'You are the deployment.' })
  29. const scope = await mintScope(ctx, 'child')
  30. scope.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
  31. const scoped = renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))
  32. const global = renderPrompt(await ctx.systemPrompt.assemble())
  33. expect(scoped).toContain('You run tests.')
  34. expect(scoped).not.toContain('You are the deployment.')
  35. expect(global).toContain('You are the deployment.')
  36. expect(global).not.toContain('You run tests.')
  37. })
  38. it('scoped-only sections join that scope alone; disposal removes them', async () => {
  39. const ctx = await mount()
  40. const scope = await mintScope(ctx, 'child')
  41. scope.ctx.systemPrompt.section({ name: 'child:extra', order: 50, text: 'Extra guidance.' })
  42. expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).toContain('Extra guidance.')
  43. expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain('Extra guidance.')
  44. await scope.dispose()
  45. expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).not.toContain('Extra guidance.')
  46. })
  47. it('duplicate names throw per layer, naming agent.ctx for the global case', async () => {
  48. const ctx = await mount()
  49. const scope = await mintScope(ctx, 'child')
  50. ctx.systemPrompt.section({ name: 'x', order: 1, text: 'a' })
  51. expect(() => ctx.systemPrompt.section({ name: 'x', order: 1, text: 'b' })).toThrow(/agent\.ctx/)
  52. scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'a' })
  53. expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
  54. })
  55. it('shadows a global section before evaluating either text provider', async () => {
  56. const ctx = await mount()
  57. const scope = await mintScope(ctx, 'child')
  58. const globalText = vi.fn(() => 'global text')
  59. const scopedText = vi.fn(() => 'scoped text')
  60. ctx.systemPrompt.section({ name: 'shared', order: 1, text: globalText })
  61. scope.ctx.systemPrompt.section({ name: 'shared', order: 1, text: scopedText })
  62. const assembly = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
  63. expect(assembly.sections.find(section => section.name === 'shared')?.text).toBe('scoped text')
  64. expect(globalText).not.toHaveBeenCalled()
  65. expect(scopedText).toHaveBeenCalledOnce()
  66. })
  67. })
  68. describe('scoped variables', () => {
  69. it('a scoped variable shadows its global name-twin for that scope', async () => {
  70. const ctx = await mount({ persona: 'Mode: {{mode}}.' })
  71. const scope = await mintScope(ctx, 'child')
  72. ctx.systemPrompt.variable('mode', () => 'normal')
  73. scope.ctx.systemPrompt.variable('mode', () => 'strict')
  74. expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).toContain('Mode: strict.')
  75. expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain('Mode: normal.')
  76. })
  77. it('same-layer duplicates throw; scoped layer cleans up on dispose', async () => {
  78. const ctx = await mount()
  79. const scope = await mintScope(ctx, 'child')
  80. scope.ctx.systemPrompt.variable('v', () => '1')
  81. expect(() => scope.ctx.systemPrompt.variable('v', () => '2')).toThrow(/already registered in this scope/)
  82. await scope.dispose()
  83. // Re-minting a scope with the SAME key starts clean.
  84. const again = await mintScope(ctx, 'child2')
  85. again.ctx.systemPrompt.variable('v', () => '3')
  86. })
  87. it('defers a scoped variable that replaces the last provider in its generation', async () => {
  88. const ctx = await mount({ persona: 'Mode: {{mode}}.' })
  89. const scope = await mintScope(ctx, 'child')
  90. const key = scopeKeyOf(scope)
  91. const calls: string[] = []
  92. scope.ctx.systemPrompt.section({ name: 'scope:sibling', order: 1, text: 'Scoped.' })
  93. const dispose = scope.ctx.systemPrompt.variable('mode', () => {
  94. calls.push('first')
  95. dispose()
  96. scope.ctx.systemPrompt.variable('mode', () => {
  97. calls.push('replacement')
  98. return 'replacement'
  99. })
  100. return 'first'
  101. })
  102. expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: first.')
  103. expect(calls).toEqual(['first'])
  104. expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: replacement.')
  105. expect(calls).toEqual(['first', 'replacement'])
  106. })
  107. })
  108. describe('scoped tool providers and toolOrder × restriction', () => {
  109. it('scoped providers are consulted only for their scope', async () => {
  110. const ctx = await mount()
  111. const scope = await mintScope(ctx, 'child')
  112. ctx.systemPrompt.tools(() => ({ schemas: [schema('global_tool')] }))
  113. scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] }))
  114. const scoped = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
  115. const global = await ctx.systemPrompt.assemble()
  116. expect(scoped.tools.map(t => t.name)).toEqual(['global_tool', 'scoped_tool'])
  117. expect(global.tools.map(t => t.name)).toEqual(['global_tool'])
  118. })
  119. it('disposing a scoped tool provider empties its layer without residue', async () => {
  120. const ctx = await mount()
  121. const scope = await mintScope(ctx, 'child')
  122. const dispose = scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] }))
  123. dispose()
  124. const after = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
  125. expect(after.tools.map(t => t.name)).toEqual([])
  126. // Re-registering through the same scope starts a fresh layer.
  127. scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('again')] }))
  128. const again = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
  129. expect(again.tools.map(t => t.name)).toEqual(['again'])
  130. })
  131. it('a toolOrder entry restricted away for a scope is a normal absence, while a typo still throws', async () => {
  132. const ctx = await mount({ toolOrder: ['bash', TOOL_ORDER_REST] })
  133. // A provider mimicking the registry's restriction split: bash exists
  134. // (knownNames) but is masked for this assembly (schemas).
  135. ctx.systemPrompt.tools(() => ({
  136. schemas: [schema('read')],
  137. knownNames: ['read', 'bash'],
  138. }))
  139. const assembly = await ctx.systemPrompt.assemble()
  140. expect(assembly.tools.map(t => t.name)).toEqual(['read'])
  141. const bad = await mount({ toolOrder: ['basj', TOOL_ORDER_REST] })
  142. bad.systemPrompt.tools(() => ({ schemas: [schema('read')], knownNames: ['read', 'bash'] }))
  143. await expect(bad.systemPrompt.assemble()).rejects.toThrow('toolOrder lists unregistered tool "basj"; known tools: bash, read')
  144. })
  145. })
  146. describe('scoped assemble dispatch', () => {
  147. it('an agent.ctx assemble listener shapes only its own scope\'s assemblies', async () => {
  148. const ctx = await mount()
  149. const scope = await mintScope(ctx, 'child')
  150. const shaped: (ScopeKey | undefined)[] = []
  151. scope.ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context, next: () => Promise<PromptAssembly>) => {
  152. shaped.push(context.scope)
  153. const result = await next()
  154. result.sections.push({ name: 'listener:extra', text: 'listener text' })
  155. return result
  156. })
  157. const scoped = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
  158. const global = await ctx.systemPrompt.assemble()
  159. expect(scoped.sections.some(s => s.name === 'listener:extra')).toBe(true)
  160. expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false)
  161. expect(shaped).toHaveLength(1)
  162. })
  163. })