tools.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import { describe, expect, expectTypeOf, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  4. import ToolRegistry, {
  5. defineTool, schemaSpecToJsonSchema,
  6. type InferArgs, type SchemaSpec, type ToolExecutionResult,
  7. } from '@deepseek-ai/dsh-tools'
  8. async function setup() {
  9. const ctx = new Context()
  10. await ctx.plugin(SystemPrompt)
  11. await ctx.plugin(ToolRegistry)
  12. return ctx
  13. }
  14. const echoTool = defineTool({
  15. name: 'echo',
  16. description: 'echo arguments back',
  17. parameters: { text: { type: 'string' } },
  18. async execute(args) {
  19. return [{ type: 'text' as const, text: args.text ?? '' }]
  20. },
  21. })
  22. describe('ToolRegistry', () => {
  23. it('registers tools, exposes schemas, and feeds the system-prompt assembly', async () => {
  24. const ctx = await setup()
  25. ctx.tools.register(echoTool)
  26. expect(ctx.tools.schemas()).toEqual([{
  27. name: 'echo',
  28. description: 'echo arguments back',
  29. parameters: { type: 'object', properties: { text: { type: 'string' } } },
  30. }])
  31. // schemas() result must not leak execute — ToolSchema deliberately has no
  32. // 'execute' key, so widen through unknown to probe for the absent property
  33. expect((ctx.tools.schemas()[0] as unknown as Record<string, unknown>).execute).toBeUndefined()
  34. const assembly = await ctx.systemPrompt.assemble()
  35. expect(assembly.tools.map(t => t.name)).toEqual(['echo'])
  36. })
  37. it('executes a tool and returns its content', async () => {
  38. const ctx = await setup()
  39. ctx.tools.register(echoTool)
  40. const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'hi' } })
  41. expect(result).toEqual({ callId: 'c1', content: [{ type: 'text', text: 'hi' }], isError: false })
  42. })
  43. it('returns isError results for unknown tools and throwing tools', async () => {
  44. const ctx = await setup()
  45. ctx.tools.register({
  46. ...echoTool,
  47. name: 'boom',
  48. async execute() {
  49. throw new Error('exploded')
  50. },
  51. })
  52. const unknown = await ctx.tools.execute({ callId: 'c1', name: 'nope', arguments: {} })
  53. expect(unknown.isError).toBe(true)
  54. const thrown = await ctx.tools.execute({ callId: 'c2', name: 'boom', arguments: {} })
  55. expect(thrown.isError).toBe(true)
  56. expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
  57. })
  58. it('lets tools/execute waterfall listeners veto a call (permission pattern)', async () => {
  59. const ctx = await setup()
  60. ctx.tools.register(echoTool)
  61. ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
  62. if (exec.name === 'echo') {
  63. return {
  64. callId: exec.callId,
  65. content: [{ type: 'text', text: 'denied by policy' }],
  66. isError: true,
  67. }
  68. }
  69. return next()
  70. })
  71. const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'hi' } })
  72. expect(result.isError).toBe(true)
  73. expect(result.content[0]).toMatchObject({ text: 'denied by policy' })
  74. })
  75. it('composes multiple tools/execute listeners (sandbox-wrap pattern)', async () => {
  76. const ctx = await setup()
  77. ctx.tools.register(echoTool)
  78. const order: string[] = []
  79. ctx.on('tools/execute', async (_exec, next) => {
  80. order.push('first:before')
  81. const result = await next()
  82. order.push('first:after')
  83. return result
  84. })
  85. ctx.on('tools/execute', async (_exec, next) => {
  86. order.push('second:before')
  87. const result = await next()
  88. order.push('second:after')
  89. return result
  90. })
  91. const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'x' } })
  92. expect(result.isError).toBe(false)
  93. expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after'])
  94. })
  95. it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
  96. const ctx = await setup()
  97. ctx.tools.register(echoTool)
  98. expect(() => ctx.tools.register(echoTool)).toThrow('already registered')
  99. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  100. inner.tools.register({ ...echoTool, name: 'scoped' })
  101. }, { inject: ['tools'] }))
  102. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'scoped'])
  103. await fiber.dispose()
  104. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
  105. })
  106. })
  107. describe('defineTool / schema DSL', () => {
  108. it('converts SchemaSpec to standard JSON Schema with required array', () => {
  109. const spec = {
  110. path: { type: 'string', required: true, description: 'Absolute path' },
  111. offset: { type: 'number' },
  112. limit: { type: 'number', description: 'Max lines' },
  113. } satisfies SchemaSpec
  114. const jsonSchema = schemaSpecToJsonSchema(spec)
  115. expect(jsonSchema).toEqual({
  116. type: 'object',
  117. properties: {
  118. path: { type: 'string', description: 'Absolute path' },
  119. offset: { type: 'number' },
  120. limit: { type: 'number', description: 'Max lines' },
  121. },
  122. required: ['path'],
  123. })
  124. })
  125. it('handles empty spec (no properties, no required)', () => {
  126. expect(schemaSpecToJsonSchema({})).toEqual({
  127. type: 'object',
  128. properties: {},
  129. })
  130. })
  131. it('handles nested object spec', () => {
  132. const spec = {
  133. config: {
  134. type: 'object',
  135. required: true,
  136. properties: {
  137. host: { type: 'string', required: true },
  138. port: { type: 'number' },
  139. },
  140. },
  141. } satisfies SchemaSpec
  142. const jsonSchema = schemaSpecToJsonSchema(spec)
  143. expect(jsonSchema).toEqual({
  144. type: 'object',
  145. properties: {
  146. config: {
  147. type: 'object',
  148. properties: {
  149. host: { type: 'string' },
  150. port: { type: 'number' },
  151. },
  152. required: ['host'],
  153. },
  154. },
  155. required: ['config'],
  156. })
  157. })
  158. it('defineTool returns a valid ToolDefinition with typed execute', async () => {
  159. const ctx = await setup()
  160. const tool = defineTool({
  161. name: 'typed-echo',
  162. description: 'A typed echo tool',
  163. parameters: {
  164. text: { type: 'string', required: true },
  165. uppercase: { type: 'boolean' },
  166. },
  167. async execute(args) {
  168. // args is typed: { text: string; uppercase?: boolean }
  169. const result = args.uppercase ? args.text.toUpperCase() : args.text
  170. return [{ type: 'text', text: result }]
  171. },
  172. })
  173. ctx.tools.register(tool)
  174. expect(ctx.tools.schemas()).toEqual([{
  175. name: 'typed-echo',
  176. description: 'A typed echo tool',
  177. parameters: {
  178. type: 'object',
  179. properties: {
  180. text: { type: 'string' },
  181. uppercase: { type: 'boolean' },
  182. },
  183. required: ['text'],
  184. },
  185. }])
  186. const result = await ctx.tools.execute({
  187. callId: 'c1',
  188. name: 'typed-echo',
  189. arguments: { text: 'hello', uppercase: true },
  190. })
  191. expect(result.isError).toBe(false)
  192. expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
  193. })
  194. it('type-level: InferArgs maps required properties to non-optional', () => {
  195. // Compile-time check: if this compiles, InferArgs is correct.
  196. // args.a is string (required), args.b is number|undefined (optional).
  197. const tool = defineTool({
  198. name: 'type-check',
  199. description: '',
  200. parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
  201. async execute(args) {
  202. // Verify types at runtime via typeof
  203. expect(typeof args.a).toBe('string')
  204. // args.b should be undefined when not provided
  205. void args
  206. return [{ type: 'text', text: args.a }]
  207. },
  208. })
  209. void tool
  210. })
  211. it('registry round-trips a defineTool definition (register→schemas→execute)', async () => {
  212. const ctx = await setup()
  213. ctx.tools.register(defineTool({
  214. name: 'roundtrip',
  215. description: 'Round-trip test',
  216. parameters: {
  217. req: { type: 'string', required: true },
  218. opt: { type: 'number', description: 'Optional number' },
  219. },
  220. async execute(args) {
  221. return [{ type: 'text', text: `${args.req}:${args.opt ?? 'none'}` }]
  222. },
  223. }))
  224. // Schema round-trip: schemas() returns standard JSON Schema
  225. const schemas = ctx.tools.schemas()
  226. expect(schemas).toHaveLength(1)
  227. expect(schemas[0]!.parameters).toEqual({
  228. type: 'object',
  229. properties: {
  230. req: { type: 'string' },
  231. opt: { type: 'number', description: 'Optional number' },
  232. },
  233. required: ['req'],
  234. })
  235. // Execution round-trip
  236. const result = await ctx.tools.execute({
  237. callId: 'c1',
  238. name: 'roundtrip',
  239. arguments: { req: 'hello' },
  240. })
  241. expect(result.isError).toBe(false)
  242. expect(result.content).toEqual([{ type: 'text', text: 'hello:none' }])
  243. })
  244. it('still accepts raw JSON-Schema ToolDefinition directly (MCP interop)', async () => {
  245. const ctx = await setup()
  246. ctx.tools.register({
  247. name: 'raw-tool',
  248. description: 'Raw JSON Schema tool (like an MCP adapter would register)',
  249. parameters: {
  250. type: 'object',
  251. properties: { path: { type: 'string' } },
  252. required: ['path'],
  253. },
  254. async execute(args: unknown) {
  255. const p = args as { path: string }
  256. return [{ type: 'text', text: p.path }]
  257. },
  258. })
  259. const schemas = ctx.tools.schemas()
  260. expect(schemas[0]!.parameters).toEqual({
  261. type: 'object',
  262. properties: { path: { type: 'string' } },
  263. required: ['path'],
  264. })
  265. const result = await ctx.tools.execute({
  266. callId: 'c1',
  267. name: 'raw-tool',
  268. arguments: { path: '/tmp' },
  269. })
  270. expect(result.isError).toBe(false)
  271. expect(result.content).toEqual([{ type: 'text', text: '/tmp' }])
  272. })
  273. })
  274. describe('schema DSL regressions (Codex review round 2)', () => {
  275. it('InferArgs makes non-required keys genuinely optional (omittable)', () => {
  276. type Args = InferArgs<{
  277. path: { type: 'string'; required: true }
  278. limit: { type: 'number' }
  279. }>
  280. expectTypeOf<Args>().toEqualTypeOf<{ path: string; limit?: number }>()
  281. // omitting the optional key is assignable — the actual regression
  282. const omitted: Args = { path: '/tmp' }
  283. expect(omitted.limit).toBeUndefined()
  284. })
  285. it('InferArgs recurses into array items, including arrays of objects', () => {
  286. type Args = InferArgs<{
  287. names: { type: 'array'; required: true; items: { type: 'string' } }
  288. servers: {
  289. type: 'array'
  290. items: {
  291. type: 'object'
  292. properties: {
  293. host: { type: 'string'; required: true }
  294. port: { type: 'number' }
  295. }
  296. }
  297. }
  298. }>
  299. expectTypeOf<Args>().toEqualTypeOf<{
  300. names: string[]
  301. servers?: { host: string; port?: number }[]
  302. }>()
  303. })
  304. it('runtime JSON Schema matches the array-of-objects inference', () => {
  305. const spec = {
  306. servers: {
  307. type: 'array',
  308. items: {
  309. type: 'object',
  310. properties: {
  311. host: { type: 'string', required: true },
  312. port: { type: 'number' },
  313. },
  314. },
  315. },
  316. } satisfies SchemaSpec
  317. expect(schemaSpecToJsonSchema(spec)).toEqual({
  318. type: 'object',
  319. properties: {
  320. servers: {
  321. type: 'array',
  322. items: {
  323. type: 'object',
  324. properties: {
  325. host: { type: 'string' },
  326. port: { type: 'number' },
  327. },
  328. required: ['host'],
  329. },
  330. },
  331. },
  332. })
  333. })
  334. it('reports messages from non-Error throws (throw { message })', async () => {
  335. const ctx = await setup()
  336. ctx.tools.register({
  337. ...echoTool,
  338. name: 'object-thrower',
  339. async execute() {
  340. // testing non-Error throws on purpose
  341. throw { message: 'denied by object' }
  342. },
  343. })
  344. const result = await ctx.tools.execute({ callId: 'c1', name: 'object-thrower', arguments: {} })
  345. expect(result.isError).toBe(true)
  346. expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' })
  347. })
  348. })