gen-tool-catalog.spec.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. /**
  2. * Guarantee tests for the tool-schema catalog generator (`scripts/gen-tool-catalog.ts`).
  3. */
  4. import { describe, expect, it } from 'vitest'
  5. import {
  6. assertManifestComplete,
  7. assertToolsHarvested,
  8. collectToolCatalog,
  9. render,
  10. type ToolCatalog,
  11. type ToolPackage,
  12. } from '../../../../scripts/gen-tool-catalog.ts'
  13. /** JSON Schema shape enough to reach the values AST extraction can't. */
  14. interface JsonSchema {
  15. type: string
  16. properties?: Record<string, JsonSchema>
  17. items?: JsonSchema
  18. enum?: string[]
  19. required?: string[]
  20. }
  21. describe('gen-tool-catalog collectToolCatalog', () => {
  22. it('boots every shipped tool package and harvests its model-facing schemas', async () => {
  23. const catalog = await collectToolCatalog()
  24. const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
  25. expect(names).toEqual([
  26. 'ask_user_question', 'bash', 'bash', 'cordis_inspect_list',
  27. 'cordis_inspect_query',
  28. 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep',
  29. 'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output',
  30. 'list_agents', 'list_agents', 'list_mcp_resource_templates', 'list_mcp_resources',
  31. 'list_subagent_models', 'lsp', 'plugin_manager', 'present', 'pwsh', 'pwsh', 'ralph',
  32. 'read', 'read_image', 'read_mcp_resource', 'run_code', 'schedule_create', 'schedule_delete',
  33. 'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search',
  34. 'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate',
  35. 'stagehand_act', 'stagehand_extract', 'stagehand_navigate', 'stagehand_observe', 'stagehand_screenshot', 'stagehand_tabs',
  36. 'str_replace_editor', 'subagent', 'team_task_create',
  37. 'team_task_get', 'team_task_list', 'team_task_update', 'terminal_close', 'terminal_list',
  38. 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write',
  39. 'update_goal', 'wait_agent', 'web_fetch', 'web_search', 'workflow', 'write',
  40. ])
  41. // Every tool carries a JSON-Schema `parameters` object (what the model sees).
  42. for (const entry of catalog) {
  43. for (const schema of entry.schemas) {
  44. expect((schema.parameters as unknown as JsonSchema).type).toBe('object')
  45. }
  46. }
  47. })
  48. it('resolves a runtime-spread enum to its literal members (the payoff over AST)', async () => {
  49. const catalog = await collectToolCatalog()
  50. const todo = catalog
  51. .flatMap(entry => entry.schemas)
  52. .find(s => s.name === 'todo_write')
  53. // `todo-todo` writes `enum: [...STATUSES]` — a source AST would see the
  54. // spread, not the values. Booting yields the shipped enum literals.
  55. const status = (((todo?.parameters as unknown as JsonSchema).properties?.todos)?.items)?.properties?.status
  56. expect(status?.enum).toEqual(['pending', 'in_progress', 'completed'])
  57. })
  58. it('attributes each harvested tool with its registering plugin source', async () => {
  59. const catalog = await collectToolCatalog()
  60. const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash')
  61. expect(bash?.sources.bash).toBe('packages/shell/tool-bash/src/index.ts')
  62. const control = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent-control')
  63. expect(control?.sources).toEqual({
  64. interrupt_agent: 'packages/subagent/tool-subagent-control/src/index.ts',
  65. list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
  66. send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
  67. })
  68. })
  69. it('harvests search tools without depending on the generator process PATH', async () => {
  70. const oldPath = process.env.PATH
  71. try {
  72. process.env.PATH = ''
  73. const catalog = await collectToolCatalog()
  74. const search = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-fs-search')
  75. expect(search?.schemas.map(s => s.name).sort()).toEqual(['glob', 'grep'])
  76. } finally {
  77. if (oldPath === undefined) delete process.env.PATH
  78. else process.env.PATH = oldPath
  79. }
  80. })
  81. it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
  82. // `tool-subagent`'s registered name is the load-time `toolName` config, so the shipped
  83. // agents surface this one package as both `subagent` and `subagent_fork`.
  84. const catalog = await collectToolCatalog()
  85. const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent')
  86. expect(subagent?.schemas.map(s => s.name)).toEqual(['list_subagent_models', 'subagent'])
  87. expect(subagent?.note).toMatch(/subagent_fork/)
  88. })
  89. })
  90. describe('gen-tool-catalog assertManifestComplete', () => {
  91. it('passes when the manifest lists every on-disk tool package (the default)', () => {
  92. expect(() => { assertManifestComplete() }).not.toThrow()
  93. })
  94. it('throws, naming the omitted package, when a tool package is missing from the manifest', () => {
  95. // An empty manifest scanned against the real tree: every `tool-*` package
  96. // is unlisted, so the guard must fire and name them.
  97. expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/)
  98. expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/)
  99. })
  100. })
  101. describe('gen-tool-catalog assertToolsHarvested', () => {
  102. const entry: ToolPackage = {
  103. pkg: '@deepseek-ai/dsh-tool-demo',
  104. dir: 'tool-demo',
  105. source: 'packages/demo/tool-demo/src/index.ts',
  106. requires: ['ctx.tools', 'ctx.somethingUnmounted'],
  107. writes: ['tool/result'],
  108. mount: () => Promise.resolve(),
  109. }
  110. it('accepts a boot that registered at least one tool', () => {
  111. expect(() => { assertToolsHarvested(entry, 1) }).not.toThrow()
  112. })
  113. it('throws, naming the package and its requirements, when a boot registers nothing', () => {
  114. // The failure this guards is silent by construction: the package is in the
  115. // manifest, its plugin merely stays PENDING on an unmounted service, and the
  116. // catalog would ship without its tools while every gate stays green.
  117. expect(() => { assertToolsHarvested(entry, 0) }).toThrow(/@deepseek-ai\/dsh-tool-demo booted without registering a single tool/)
  118. expect(() => { assertToolsHarvested(entry, 0) }).toThrow(/ctx.somethingUnmounted/)
  119. })
  120. })
  121. describe('gen-tool-catalog render', () => {
  122. it('emits a package heading, a tool heading, and a json schema fence', () => {
  123. const catalog: ToolCatalog = [
  124. {
  125. pkg: '@deepseek-ai/dsh-tool-demo',
  126. sources: { demo: 'packages/demo/tool-demo/src/index.ts' },
  127. requires: ['ctx.tools'],
  128. writes: ['tool/result'],
  129. schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }],
  130. },
  131. ]
  132. const md = render(catalog)
  133. expect(md).toContain('| `@deepseek-ai/dsh-tool-demo` | `demo` | `ctx.tools` | `tool/result` |')
  134. expect(md).toContain('## `@deepseek-ai/dsh-tool-demo`')
  135. expect(md).toContain('### `demo`')
  136. expect(md).toContain('A demo tool.')
  137. expect(md).toContain('```json')
  138. expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]')
  139. })
  140. })