tool-order.spec.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import SystemPrompt, { PromptAssembly, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
  4. import type { ToolSchema } from '@deepseek-ai/dsh-llm'
  5. function tool(name: string, description = name): ToolSchema {
  6. return { name, description, parameters: { type: 'object', properties: {} } }
  7. }
  8. async function mount(config: { persona?: string; toolOrder?: string[] } = {}): Promise<Context> {
  9. const ctx = new Context()
  10. await ctx.plugin(SystemPrompt, config)
  11. return ctx
  12. }
  13. function names(assembly: PromptAssembly): string[] {
  14. return assembly.tools.map(t => t.name)
  15. }
  16. describe('SystemPrompt tool order', () => {
  17. // The ONE place the public constant's value is pinned; everything else
  18. // (tests and deployment configs alike) references TOOL_ORDER_REST.
  19. it('exports the rest entry as "<unlisted-tools>"', () => {
  20. expect(TOOL_ORDER_REST).toBe('<unlisted-tools>')
  21. })
  22. it('assembles tools in lexicographic name order when no toolOrder is configured', async () => {
  23. const ctx = await mount()
  24. ctx.systemPrompt.tools(() => ({ schemas: [tool('charlie'), tool('alpha')] }))
  25. ctx.systemPrompt.tools(() => ({ schemas: [tool('bravo')] }))
  26. expect(names(await ctx.systemPrompt.assemble())).toEqual(['alpha', 'bravo', 'charlie'])
  27. })
  28. it('assembles the same order regardless of provider registration order', async () => {
  29. const forward = await mount()
  30. forward.systemPrompt.tools(() => ({ schemas: [tool('alpha')] }))
  31. forward.systemPrompt.tools(() => ({ schemas: [tool('zulu')] }))
  32. const backward = await mount()
  33. backward.systemPrompt.tools(() => ({ schemas: [tool('zulu')] }))
  34. backward.systemPrompt.tools(() => ({ schemas: [tool('alpha')] }))
  35. expect(names(await forward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
  36. expect(names(await backward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
  37. })
  38. it('applies a configured toolOrder: listed positions, rest at the rest entry lexicographically', async () => {
  39. const ctx = await mount({ toolOrder: ['todo_write', TOOL_ORDER_REST, 'bash'] })
  40. ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')] }))
  41. expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
  42. })
  43. it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => {
  44. const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] })
  45. ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] }))
  46. await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
  47. 'toolOrder lists unregistered tools "ghost", "wraith"; known tools: bash, todo_write')
  48. })
  49. it('names the single unregistered tool when no tools are registered at all', async () => {
  50. const ctx = await mount({ toolOrder: ['ghost', TOOL_ORDER_REST] })
  51. await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
  52. 'toolOrder lists unregistered tool "ghost"; known tools: (none)')
  53. })
  54. it.each([
  55. ['without an explicit toolOrder', undefined],
  56. ['with only the rest entry configured', [TOOL_ORDER_REST]],
  57. ])('rejects a provider tool named like the reserved rest entry %s', async (_case, toolOrder) => {
  58. const ctx = await mount(toolOrder === undefined ? {} : { toolOrder })
  59. ctx.systemPrompt.tools(() => ({ schemas: [tool(TOOL_ORDER_REST)] }))
  60. await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
  61. `tool provider returned reserved tool name "${TOOL_ORDER_REST}"`)
  62. })
  63. it('keeps collection order between tools that share a name (stable sort)', async () => {
  64. const ctx = await mount()
  65. ctx.systemPrompt.tools(() => ({ schemas: [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')] }))
  66. const assembly = await ctx.systemPrompt.assemble()
  67. expect(assembly.tools.map(t => t.description)).toEqual(['anchor', 'first', 'second'])
  68. })
  69. it('canonicalizes BEFORE the assemble waterfall: listeners see the ordered list and own their own edits', async () => {
  70. const ctx = await mount()
  71. ctx.systemPrompt.tools(() => ({ schemas: [tool('zulu'), tool('alpha')] }))
  72. let seen: string[] | undefined
  73. ctx.on('system-prompt/assemble', function (assembly, _context, next) {
  74. seen = assembly.tools.map(t => t.name)
  75. // A listener-appended tool is NOT re-sorted — same contract as sections:
  76. // canonicalization applies to what the registry contributed, and a
  77. // listener owns the determinism of what it emits.
  78. assembly.tools.push(tool('aardvark'))
  79. return next()
  80. })
  81. const assembly = await ctx.systemPrompt.assemble()
  82. expect(seen).toEqual(['alpha', 'zulu'])
  83. expect(names(assembly)).toEqual(['alpha', 'zulu', 'aardvark'])
  84. })
  85. it.each([
  86. ['an empty list', []],
  87. ['a list without the rest entry', ['bash', 'todo_write']],
  88. ])('rejects %s at load (the rest entry is required)', async (_case, toolOrder) => {
  89. await expect(new Context().plugin(SystemPrompt, { toolOrder })).rejects.toThrow(`must contain the "${TOOL_ORDER_REST}" rest entry`)
  90. })
  91. it.each([
  92. ['a duplicate tool name', ['bash', 'bash', TOOL_ORDER_REST]],
  93. ['a duplicate rest entry', [TOOL_ORDER_REST, 'bash', TOOL_ORDER_REST]],
  94. ])('rejects %s at load', async (_case, toolOrder) => {
  95. await expect(new Context().plugin(SystemPrompt, { toolOrder })).rejects.toThrow('more than once')
  96. })
  97. it('throws from direct construction too', () => {
  98. expect(() => new SystemPrompt(new Context(), { toolOrder: ['bash'] })).toThrow('rest entry')
  99. })
  100. })