gen-tool-catalog.spec.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. /**
  2. * Guarantee tests for the tool-schema catalog generator
  3. * (`scripts/gen-tool-catalog.ts`).
  4. *
  5. * The generated catalog is frozen by a regenerate-and-diff freshness gate, so
  6. * the freshness half is exercised by `pnpm run verify-tool-catalog` in CI. What
  7. * a freshness diff CANNOT prove is (a) that BOOTING the tool plugins yields the
  8. * shipped schema — the whole reason this generator boots instead of parsing
  9. * source (a runtime-spread enum resolves to its literal members) — and (b) that
  10. * the completeness guard REJECTS a tool package missing from the boot manifest,
  11. * the property that replaces the AST pass's "nothing silently omitted". These
  12. * tests drive the exported `collectToolCatalog` / `assertManifestComplete` /
  13. * `render` directly, mirroring the negative-path style of the cordis-catalog
  14. * generator tests.
  15. */
  16. import { describe, expect, it } from 'vitest'
  17. import {
  18. assertManifestComplete,
  19. collectToolCatalog,
  20. render,
  21. type ToolCatalog,
  22. } from '../../../../scripts/gen-tool-catalog.ts'
  23. /** JSON Schema shape enough to reach the values AST extraction can't. */
  24. interface JsonSchema {
  25. type: string
  26. properties?: Record<string, JsonSchema>
  27. items?: JsonSchema
  28. enum?: string[]
  29. required?: string[]
  30. }
  31. describe('gen-tool-catalog collectToolCatalog', () => {
  32. it('boots every shipped tool package and harvests its model-facing schemas', async () => {
  33. const catalog = await collectToolCatalog()
  34. const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
  35. expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
  36. // Every tool carries a JSON-Schema `parameters` object (what the model sees).
  37. for (const entry of catalog) {
  38. for (const schema of entry.schemas) {
  39. expect((schema.parameters as unknown as JsonSchema).type).toBe('object')
  40. }
  41. }
  42. })
  43. it('resolves a runtime-spread enum to its literal members (the payoff over AST)', async () => {
  44. const catalog = await collectToolCatalog()
  45. const todo = catalog
  46. .flatMap(entry => entry.schemas)
  47. .find(s => s.name === 'todo_write')
  48. // `todo-todo` writes `enum: [...STATUSES]` — a source AST would see the
  49. // spread, not the values. Booting yields the shipped enum literals.
  50. const status = (((todo?.parameters as unknown as JsonSchema).properties?.todos)?.items)?.properties?.status
  51. expect(status?.enum).toEqual(['pending', 'in_progress', 'completed'])
  52. })
  53. it('attributes each package with a source pointer that names its index', async () => {
  54. const catalog = await collectToolCatalog()
  55. const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash')
  56. expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts')
  57. })
  58. it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
  59. // `tool-subagent`'s registered name is the load-time `toolName` config, so
  60. // the shipped agents surface this one package as both `subagent` and
  61. // `subagent_fork`. Booting yields only the default name; the note is how a
  62. // reader learns the fork alias the model also sees. Without it the catalog
  63. // would silently under-report the shipped tool surface.
  64. const catalog = await collectToolCatalog()
  65. const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent')
  66. expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent'])
  67. expect(subagent?.note).toMatch(/subagent_fork/)
  68. })
  69. })
  70. describe('gen-tool-catalog assertManifestComplete', () => {
  71. it('passes when the manifest lists every on-disk tool package (the default)', () => {
  72. expect(() => { assertManifestComplete() }).not.toThrow()
  73. })
  74. it('throws, naming the omitted package, when a tool package is missing from the manifest', () => {
  75. // An empty manifest scanned against the real tree: every `tool-*` package
  76. // is unlisted, so the guard must fire and name them.
  77. expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/)
  78. expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/)
  79. })
  80. })
  81. describe('gen-tool-catalog render', () => {
  82. it('emits a package heading, a tool heading, and a json schema fence', () => {
  83. const catalog: ToolCatalog = [
  84. {
  85. pkg: '@deepseek-ai/dsh-tool-demo',
  86. source: 'packages/demo/tool-demo/src/index.ts',
  87. schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }],
  88. },
  89. ]
  90. const md = render(catalog)
  91. expect(md).toContain('## `@deepseek-ai/dsh-tool-demo`')
  92. expect(md).toContain('### `demo`')
  93. expect(md).toContain('A demo tool.')
  94. expect(md).toContain('```json')
  95. expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]')
  96. })
  97. it('renders the strict flag when a schema sets it', () => {
  98. const catalog: ToolCatalog = [
  99. {
  100. pkg: '@deepseek-ai/dsh-tool-demo',
  101. source: 'packages/demo/tool-demo/src/index.ts',
  102. schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }],
  103. },
  104. ]
  105. expect(render(catalog)).toContain('Strict: `true`')
  106. })
  107. })