tools.spec.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947
  1. import { describe, expect, expectTypeOf, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
  4. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  5. import ToolRegistry, {
  6. defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
  7. type InferArgs, type SchemaSpec, type ToolExecutionResult,
  8. } from '@deepseek-ai/dsh-tools'
  9. async function setup() {
  10. const ctx = new Context()
  11. await ctx.plugin(SystemPrompt)
  12. await ctx.plugin(ToolRegistry)
  13. return ctx
  14. }
  15. const echoTool = defineTool({
  16. name: 'echo',
  17. description: 'echo arguments back',
  18. parameters: { text: { type: 'string' } },
  19. async execute(args) {
  20. return [{ type: 'text' as const, text: args.text ?? '' }]
  21. },
  22. })
  23. describe('ToolRegistry', () => {
  24. it('registers tools, exposes schemas, and feeds the system-prompt assembly', async () => {
  25. const ctx = await setup()
  26. ctx.tools.register(echoTool)
  27. expect(ctx.tools.schemas()).toEqual([{
  28. name: 'echo',
  29. description: 'echo arguments back',
  30. parameters: { type: 'object', properties: { text: { type: 'string' } } },
  31. }])
  32. // schemas() result must not leak execute — ToolSchema deliberately has no
  33. // 'execute' key, so widen through unknown to probe for the absent property
  34. expect((ctx.tools.schemas()[0] as unknown as Record<string, unknown>).execute).toBeUndefined()
  35. const assembly = await ctx.systemPrompt.assemble()
  36. expect(assembly.tools.map(t => t.name)).toEqual(['echo'])
  37. })
  38. it('schemas() drops the UI presentation callbacks — they must never reach the model', async () => {
  39. const ctx = await setup()
  40. // A tool that declares presentCall/presentResult (functions). schemas() feeds
  41. // the system-prompt assembly → the model request, so those callbacks (and
  42. // `execute`) must be stripped: a function in the JSON tool schema would
  43. // corrupt the request. schemas() is an explicit allowlist, so it can't leak.
  44. ctx.tools.register(defineTool({
  45. name: 'present',
  46. description: 'has presenters',
  47. parameters: { x: { type: 'string', required: true } },
  48. async execute() { return [] },
  49. presentCall: args => ({ title: args.x }),
  50. presentResult: (args, result) => ({ title: args.x, content: result.content }),
  51. }))
  52. const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
  53. expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
  54. expect(schema.presentCall).toBeUndefined()
  55. expect(schema.presentResult).toBeUndefined()
  56. expect(schema.execute).toBeUndefined()
  57. })
  58. it('schemas() preserves `strict` when set (allowlist keeps the model-facing fields)', async () => {
  59. const ctx = await setup()
  60. ctx.tools.register(defineTool({
  61. name: 'strict-tool',
  62. description: 'd',
  63. parameters: { x: { type: 'string', required: true } },
  64. strict: true,
  65. async execute() { return [] },
  66. }))
  67. expect(ctx.tools.schemas()[0]).toMatchObject({ name: 'strict-tool', strict: true })
  68. })
  69. it('executes a tool and returns its content', async () => {
  70. const ctx = await setup()
  71. ctx.tools.register(echoTool)
  72. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  73. expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
  74. })
  75. it('returns isError results for unknown tools and throwing tools', async () => {
  76. const ctx = await setup()
  77. ctx.tools.register({
  78. ...echoTool,
  79. name: 'boom',
  80. async execute() {
  81. throw new Error('exploded')
  82. },
  83. })
  84. const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'nope', arguments: {} })
  85. expect(unknown.isError).toBe(true)
  86. expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' })
  87. // An unknown tool is a routable failure class, same as a tool-thrown one.
  88. expect(unknown.error).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' })
  89. const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} })
  90. expect(thrown.isError).toBe(true)
  91. expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
  92. })
  93. it('ToolNotFoundError carries the tool name and a stable code', async () => {
  94. const { HarnessError } = await import('@deepseek-ai/dsh-llm')
  95. const err = new ToolNotFoundError('ghost')
  96. expect(err).toBeInstanceOf(HarnessError)
  97. expect(err.name).toBe('ToolNotFoundError')
  98. expect(err.code).toBe('UNKNOWN_TOOL')
  99. expect(err.toolName).toBe('ghost')
  100. expect(err.message).toBe('unknown tool "ghost"')
  101. })
  102. it('lets tools/execute waterfall listeners veto a call (permission pattern)', async () => {
  103. const ctx = await setup()
  104. ctx.tools.register(echoTool)
  105. ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
  106. if (exec.name === 'echo') {
  107. return {
  108. callId: exec.callId,
  109. content: [{ type: 'text', text: 'denied by policy' }],
  110. isError: true,
  111. }
  112. }
  113. return next()
  114. })
  115. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  116. expect(result.isError).toBe(true)
  117. expect(result.content[0]).toMatchObject({ text: 'denied by policy' })
  118. })
  119. it('composes multiple tools/execute listeners (sandbox-wrap pattern)', async () => {
  120. const ctx = await setup()
  121. ctx.tools.register(echoTool)
  122. const order: string[] = []
  123. ctx.on('tools/execute', async (_exec, next) => {
  124. order.push('first:before')
  125. const result = await next()
  126. order.push('first:after')
  127. return result
  128. })
  129. ctx.on('tools/execute', async (_exec, next) => {
  130. order.push('second:before')
  131. const result = await next()
  132. order.push('second:after')
  133. return result
  134. })
  135. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
  136. expect(result.isError).toBe(false)
  137. expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after'])
  138. })
  139. it('returns an isError result when a tools/execute listener throws', async () => {
  140. const ctx = await setup()
  141. ctx.tools.register(echoTool)
  142. ctx.on('tools/execute', async () => {
  143. throw new Error('permission hook broke')
  144. })
  145. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  146. expect(result).toEqual({
  147. callId: CallId('c1'),
  148. content: [{ type: 'text', text: 'Error: permission hook broke' }],
  149. isError: true,
  150. })
  151. })
  152. it('preserves structured error info when a tools/execute listener throws HarnessError', async () => {
  153. const ctx = await setup()
  154. ctx.tools.register(echoTool)
  155. ctx.on('tools/execute', async () => {
  156. throw new HarnessError('denied', 'DENIED')
  157. })
  158. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  159. expect(result).toMatchObject({
  160. callId: CallId('c1'),
  161. isError: true,
  162. error: { name: 'HarnessError', code: 'DENIED' },
  163. })
  164. })
  165. it('schemas() snapshots tool schemas instead of exposing registry objects', async () => {
  166. const ctx = await setup()
  167. ctx.tools.register(echoTool)
  168. const first = ctx.tools.schemas()
  169. const firstParameters = first[0]!.parameters as { properties: Record<string, unknown> }
  170. firstParameters.properties['mutated'] = { type: 'string' }
  171. first[0]!.description = 'mutated'
  172. expect(ctx.tools.schemas()).toEqual([{
  173. name: 'echo',
  174. description: 'echo arguments back',
  175. parameters: { type: 'object', properties: { text: { type: 'string' } } },
  176. }])
  177. })
  178. it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
  179. const ctx = await setup()
  180. ctx.tools.register(echoTool)
  181. expect(() => ctx.tools.register(echoTool)).toThrow('already registered')
  182. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  183. inner.tools.register({ ...echoTool, name: 'scoped' })
  184. }, { inject: ['tools'] }))
  185. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'scoped'])
  186. await fiber.dispose()
  187. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
  188. })
  189. it('returns a callable disposer from register() that unregisters the tool', async () => {
  190. const ctx = await setup()
  191. ctx.tools.register(echoTool)
  192. // Register a second tool and call its returned disposer directly
  193. const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' })
  194. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable'])
  195. dispose()
  196. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
  197. })
  198. it('rolls back the tool entry when a tools/change listener throws (P1-1)', async () => {
  199. const ctx = await setup()
  200. let threw = false
  201. ctx.on('tools/change', () => {
  202. if (!threw) { threw = true; throw new Error('boom change listener') }
  203. })
  204. // The throwing emit must roll the entry back, not leak it.
  205. expect(() => ctx.tools.register(echoTool)).toThrow('boom change listener')
  206. expect(ctx.tools.get('echo')).toBeUndefined() // rolled back, not leaked
  207. expect(ctx.tools.schemas()).toHaveLength(0)
  208. // A subsequent listener-free register of the SAME name succeeds and is
  209. // exposed exactly once (the duplicate-name check is not wedged).
  210. const dispose = ctx.tools.register(echoTool)
  211. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
  212. dispose()
  213. expect(ctx.tools.get('echo')).toBeUndefined()
  214. })
  215. })
  216. describe('defineTool / schema DSL', () => {
  217. it('converts SchemaSpec to standard JSON Schema with required array', () => {
  218. const spec = {
  219. path: { type: 'string', required: true, description: 'Absolute path' },
  220. offset: { type: 'number' },
  221. limit: { type: 'number', description: 'Max lines' },
  222. } satisfies SchemaSpec
  223. const jsonSchema = schemaSpecToJsonSchema(spec)
  224. expect(jsonSchema).toEqual({
  225. type: 'object',
  226. properties: {
  227. path: { type: 'string', description: 'Absolute path' },
  228. offset: { type: 'number' },
  229. limit: { type: 'number', description: 'Max lines' },
  230. },
  231. required: ['path'],
  232. })
  233. })
  234. it('handles empty spec (no properties, no required)', () => {
  235. expect(schemaSpecToJsonSchema({})).toEqual({
  236. type: 'object',
  237. properties: {},
  238. })
  239. })
  240. it('handles nested object spec', () => {
  241. const spec = {
  242. config: {
  243. type: 'object',
  244. required: true,
  245. properties: {
  246. host: { type: 'string', required: true },
  247. port: { type: 'number' },
  248. },
  249. },
  250. } satisfies SchemaSpec
  251. const jsonSchema = schemaSpecToJsonSchema(spec)
  252. expect(jsonSchema).toEqual({
  253. type: 'object',
  254. properties: {
  255. config: {
  256. type: 'object',
  257. properties: {
  258. host: { type: 'string' },
  259. port: { type: 'number' },
  260. },
  261. required: ['host'],
  262. },
  263. },
  264. required: ['config'],
  265. })
  266. })
  267. it('defineTool returns a valid ToolDefinition with typed execute', async () => {
  268. const ctx = await setup()
  269. const tool = defineTool({
  270. name: 'typed-echo',
  271. description: 'A typed echo tool',
  272. parameters: {
  273. text: { type: 'string', required: true },
  274. uppercase: { type: 'boolean' },
  275. },
  276. async execute(args) {
  277. // args is typed: { text: string; uppercase?: boolean }
  278. const result = args.uppercase ? args.text.toUpperCase() : args.text
  279. return [{ type: 'text', text: result }]
  280. },
  281. })
  282. ctx.tools.register(tool)
  283. expect(ctx.tools.schemas()).toEqual([{
  284. name: 'typed-echo',
  285. description: 'A typed echo tool',
  286. parameters: {
  287. type: 'object',
  288. properties: {
  289. text: { type: 'string' },
  290. uppercase: { type: 'boolean' },
  291. },
  292. required: ['text'],
  293. },
  294. }])
  295. const result = await ctx.tools.execute({
  296. callId: CallId('c1'),
  297. name: 'typed-echo',
  298. arguments: { text: 'hello', uppercase: true },
  299. })
  300. expect(result.isError).toBe(false)
  301. expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
  302. })
  303. it('type-level: InferArgs maps required properties to non-optional', () => {
  304. // Compile-time check: if this compiles, InferArgs is correct.
  305. // args.a is string (required), args.b is number|undefined (optional).
  306. const tool = defineTool({
  307. name: 'type-check',
  308. description: '',
  309. parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
  310. async execute(args) {
  311. // Verify types at runtime via typeof
  312. expect(typeof args.a).toBe('string')
  313. // args.b should be undefined when not provided
  314. void args
  315. return [{ type: 'text', text: args.a }]
  316. },
  317. })
  318. void tool
  319. })
  320. it('registry round-trips a defineTool definition (register→schemas→execute)', async () => {
  321. const ctx = await setup()
  322. ctx.tools.register(defineTool({
  323. name: 'roundtrip',
  324. description: 'Round-trip test',
  325. parameters: {
  326. req: { type: 'string', required: true },
  327. opt: { type: 'number', description: 'Optional number' },
  328. },
  329. async execute(args) {
  330. return [{ type: 'text', text: `${args.req}:${args.opt ?? 'none'}` }]
  331. },
  332. }))
  333. // Schema round-trip: schemas() returns standard JSON Schema
  334. const schemas = ctx.tools.schemas()
  335. expect(schemas).toHaveLength(1)
  336. expect(schemas[0]!.parameters).toEqual({
  337. type: 'object',
  338. properties: {
  339. req: { type: 'string' },
  340. opt: { type: 'number', description: 'Optional number' },
  341. },
  342. required: ['req'],
  343. })
  344. // Execution round-trip
  345. const result = await ctx.tools.execute({
  346. callId: CallId('c1'),
  347. name: 'roundtrip',
  348. arguments: { req: 'hello' },
  349. })
  350. expect(result.isError).toBe(false)
  351. expect(result.content).toEqual([{ type: 'text', text: 'hello:none' }])
  352. })
  353. it('still accepts raw JSON-Schema ToolDefinition directly (MCP interop)', async () => {
  354. const ctx = await setup()
  355. ctx.tools.register({
  356. name: 'raw-tool',
  357. description: 'Raw JSON Schema tool (like an MCP adapter would register)',
  358. parameters: {
  359. type: 'object',
  360. properties: { path: { type: 'string' } },
  361. required: ['path'],
  362. },
  363. async execute(args: unknown) {
  364. const p = args as { path: string }
  365. return [{ type: 'text', text: p.path }]
  366. },
  367. })
  368. const schemas = ctx.tools.schemas()
  369. expect(schemas[0]!.parameters).toEqual({
  370. type: 'object',
  371. properties: { path: { type: 'string' } },
  372. required: ['path'],
  373. })
  374. const result = await ctx.tools.execute({
  375. callId: CallId('c1'),
  376. name: 'raw-tool',
  377. arguments: { path: '/tmp' },
  378. })
  379. expect(result.isError).toBe(false)
  380. expect(result.content).toEqual([{ type: 'text', text: '/tmp' }])
  381. })
  382. })
  383. describe('schema DSL edge cases', () => {
  384. it('emits enum values in JSON Schema property', () => {
  385. const spec = {
  386. color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' },
  387. } satisfies SchemaSpec
  388. const jsonSchema = schemaSpecToJsonSchema(spec)
  389. expect(jsonSchema.properties['color']).toMatchObject({
  390. type: 'string',
  391. enum: ['red', 'green', 'blue'],
  392. description: 'Color choice',
  393. })
  394. })
  395. it('emits default value in JSON Schema property', () => {
  396. const spec = {
  397. limit: { type: 'number', default: 25 },
  398. } satisfies SchemaSpec
  399. const jsonSchema = schemaSpecToJsonSchema(spec)
  400. expect(jsonSchema.properties['limit']).toMatchObject({
  401. type: 'number',
  402. default: 25,
  403. })
  404. })
  405. it('handles array items without nested properties (plain type array)', () => {
  406. const spec = {
  407. tags: { type: 'array', items: { type: 'string' } },
  408. } satisfies SchemaSpec
  409. const jsonSchema = schemaSpecToJsonSchema(spec)
  410. expect(jsonSchema.properties['tags']).toEqual({
  411. type: 'array',
  412. items: { type: 'string' },
  413. })
  414. })
  415. it('defineTool passes through strict flag when set to true', () => {
  416. const tool = defineTool({
  417. name: 'strict-tool',
  418. description: 'A strict tool',
  419. parameters: { input: { type: 'string' } },
  420. strict: true,
  421. async execute(args) {
  422. return [{ type: 'text' as const, text: args.input ?? '' }]
  423. },
  424. })
  425. expect(tool.strict).toBe(true)
  426. })
  427. it('defineTool omits strict when not provided', () => {
  428. const tool = defineTool({
  429. name: 'non-strict-tool',
  430. description: 'A non-strict tool',
  431. parameters: { input: { type: 'string' } },
  432. async execute(args) {
  433. return [{ type: 'text' as const, text: args.input ?? '' }]
  434. },
  435. })
  436. expect('strict' in tool).toBe(false)
  437. })
  438. it('defineTool strict=false is included', () => {
  439. const tool = defineTool({
  440. name: 'explicitly-non-strict',
  441. description: 'Explicitly non-strict',
  442. parameters: { input: { type: 'string' } },
  443. strict: false,
  444. async execute(args) {
  445. return [{ type: 'text' as const, text: args.input ?? '' }]
  446. },
  447. })
  448. expect(tool.strict).toBe(false)
  449. })
  450. it('handles enum and default together in one property', () => {
  451. const spec = {
  452. level: { type: 'string', enum: ['low', 'high'], default: 'low' },
  453. } satisfies SchemaSpec
  454. const jsonSchema = schemaSpecToJsonSchema(spec)
  455. expect(jsonSchema.properties['level']).toMatchObject({
  456. type: 'string',
  457. enum: ['low', 'high'],
  458. default: 'low',
  459. })
  460. })
  461. it('omits description, enum, default keys when not specified', () => {
  462. const spec = {
  463. bare: { type: 'string' },
  464. } satisfies SchemaSpec
  465. const jsonSchema = schemaSpecToJsonSchema(spec)
  466. const prop = jsonSchema.properties['bare'] as Record<string, unknown>
  467. expect(prop).toEqual({ type: 'string' })
  468. expect('description' in prop).toBe(false)
  469. expect('enum' in prop).toBe(false)
  470. expect('default' in prop).toBe(false)
  471. })
  472. it('handles array with no items (items omitted)', () => {
  473. const spec = {
  474. raw: { type: 'array' },
  475. } satisfies SchemaSpec
  476. const jsonSchema = schemaSpecToJsonSchema(spec)
  477. expect(jsonSchema.properties['raw']).toEqual({
  478. type: 'array',
  479. })
  480. })
  481. it('handles nested object with all-optional properties (no required array)', () => {
  482. const spec = {
  483. config: {
  484. type: 'object',
  485. properties: {
  486. host: { type: 'string' },
  487. port: { type: 'number' },
  488. },
  489. },
  490. } satisfies SchemaSpec
  491. const jsonSchema = schemaSpecToJsonSchema(spec)
  492. expect(jsonSchema.properties['config']).toMatchObject({
  493. type: 'object',
  494. properties: {
  495. host: { type: 'string' },
  496. port: { type: 'number' },
  497. },
  498. })
  499. // no 'required' key in the nested object because nothing is required
  500. const config = jsonSchema.properties['config'] as Record<string, unknown>
  501. expect('required' in config).toBe(false)
  502. })
  503. })
  504. describe('schema DSL regressions (Codex review round 2)', () => {
  505. it('InferArgs makes non-required keys genuinely optional (omittable)', () => {
  506. type Args = InferArgs<{
  507. path: { type: 'string'; required: true }
  508. limit: { type: 'number' }
  509. }>
  510. expectTypeOf<Args>().toEqualTypeOf<{ path: string; limit?: number }>()
  511. // omitting the optional key is assignable — the actual regression
  512. const omitted: Args = { path: '/tmp' }
  513. expect(omitted.limit).toBeUndefined()
  514. })
  515. it('InferArgs recurses into array items, including arrays of objects', () => {
  516. type Args = InferArgs<{
  517. names: { type: 'array'; required: true; items: { type: 'string' } }
  518. servers: {
  519. type: 'array'
  520. items: {
  521. type: 'object'
  522. properties: {
  523. host: { type: 'string'; required: true }
  524. port: { type: 'number' }
  525. }
  526. }
  527. }
  528. }>
  529. expectTypeOf<Args>().toEqualTypeOf<{
  530. names: string[]
  531. servers?: { host: string; port?: number }[]
  532. }>()
  533. })
  534. it('runtime JSON Schema matches the array-of-objects inference', () => {
  535. const spec = {
  536. servers: {
  537. type: 'array',
  538. items: {
  539. type: 'object',
  540. properties: {
  541. host: { type: 'string', required: true },
  542. port: { type: 'number' },
  543. },
  544. },
  545. },
  546. } satisfies SchemaSpec
  547. expect(schemaSpecToJsonSchema(spec)).toEqual({
  548. type: 'object',
  549. properties: {
  550. servers: {
  551. type: 'array',
  552. items: {
  553. type: 'object',
  554. properties: {
  555. host: { type: 'string' },
  556. port: { type: 'number' },
  557. },
  558. required: ['host'],
  559. },
  560. },
  561. },
  562. })
  563. })
  564. it('reports messages from non-Error throws (throw { message })', async () => {
  565. const ctx = await setup()
  566. ctx.tools.register({
  567. ...echoTool,
  568. name: 'object-thrower',
  569. async execute() {
  570. // testing non-Error throws on purpose
  571. throw { message: 'denied by object' }
  572. },
  573. })
  574. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-thrower', arguments: {} })
  575. expect(result.isError).toBe(true)
  576. expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' })
  577. })
  578. it('reports messages from throws of non-objects (throw "string")', async () => {
  579. const ctx = await setup()
  580. ctx.tools.register({
  581. ...echoTool,
  582. name: 'string-thrower',
  583. async execute() {
  584. // testing primitive throws on purpose
  585. throw 'kaboom'
  586. },
  587. })
  588. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'string-thrower', arguments: {} })
  589. expect(result.isError).toBe(true)
  590. expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
  591. })
  592. it('reports messages from throws of objects without message property', async () => {
  593. const ctx = await setup()
  594. ctx.tools.register({
  595. ...echoTool,
  596. name: 'object-no-message',
  597. async execute() {
  598. // testing object throw without .message
  599. throw { code: 500 }
  600. },
  601. })
  602. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-no-message', arguments: {} })
  603. expect(result.isError).toBe(true)
  604. const firstContent = result.content[0]!
  605. expect(firstContent.type).toBe('text')
  606. if (firstContent.type === 'text') {
  607. expect(firstContent.text).toBe('Error: [object Object]')
  608. }
  609. })
  610. })
  611. describe('ToolRegistry.get', () => {
  612. it('get() returns the registered tool definition', async () => {
  613. const ctx = await setup()
  614. ctx.tools.register(echoTool)
  615. const tool = ctx.tools.get('echo')
  616. expect(tool).toBeDefined()
  617. expect(tool!.name).toBe('echo')
  618. })
  619. it('get() returns undefined for unknown tool names', async () => {
  620. const ctx = await setup()
  621. expect(ctx.tools.get('nope')).toBeUndefined()
  622. })
  623. })
  624. describe('validateArgs (the runtime-validation RFC, part 1)', () => {
  625. it('returns [] for valid args and is total over malformed input', () => {
  626. const spec = {
  627. path: { type: 'string', required: true },
  628. limit: { type: 'number' },
  629. } satisfies SchemaSpec
  630. expect(validateArgs(spec, { path: '/tmp' })).toEqual([])
  631. expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([])
  632. // never throws regardless of shape
  633. expect(validateArgs(spec, null)).toHaveLength(1)
  634. expect(validateArgs(spec, 'nope')).toHaveLength(1)
  635. expect(validateArgs(spec, [])).toHaveLength(1)
  636. })
  637. it('flags a missing required key and a required key present as undefined', () => {
  638. const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
  639. expect(validateArgs(spec, {})).toEqual(['missing required property "path"'])
  640. expect(validateArgs(spec, { path: undefined })).toEqual(['missing required property "path"'])
  641. })
  642. it('allows extra keys (no additionalProperties:false) and omitted optionals', () => {
  643. const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
  644. expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([])
  645. })
  646. it('does not apply defaults (validation only)', () => {
  647. const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec
  648. // absent optional is valid, and validation does not synthesize the default
  649. expect(validateArgs(spec, {})).toEqual([])
  650. })
  651. it('type-checks primitives', () => {
  652. const spec = {
  653. s: { type: 'string' },
  654. n: { type: 'number' },
  655. b: { type: 'boolean' },
  656. } satisfies SchemaSpec
  657. expect(validateArgs(spec, { s: 1 })).toEqual(['"s" must be a string'])
  658. expect(validateArgs(spec, { n: 'x' })).toEqual(['"n" must be a number'])
  659. expect(validateArgs(spec, { b: 'x' })).toEqual(['"b" must be a boolean'])
  660. })
  661. it('checks enum membership', () => {
  662. const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies SchemaSpec
  663. expect(validateArgs(spec, { color: 'red' })).toEqual([])
  664. expect(validateArgs(spec, { color: 'blue' })).toEqual(['"color" must be one of ["red","green"]'])
  665. })
  666. it('checks enum uniformly with the converter (enum on a non-string prop)', () => {
  667. // The converter emits `enum` regardless of type; the validator must agree.
  668. // `enum` is string[], so a number value can never be a member.
  669. const spec = { n: { type: 'number', enum: ['1', '2'] } } as unknown as SchemaSpec
  670. expect(validateArgs(spec, { n: 1 })).toEqual(['"n" must be one of ["1","2"]'])
  671. })
  672. it('rejects an unknown SchemaType at runtime (assertNever guard)', () => {
  673. const spec = { x: { type: 'weird' } } as unknown as SchemaSpec
  674. expect(() => validateArgs(spec, { x: 1 })).toThrow(/unreachable variant.*validateArgs/)
  675. })
  676. it('recurses into nested objects (and an object without properties only type-checks)', () => {
  677. const spec = {
  678. config: {
  679. type: 'object',
  680. required: true,
  681. properties: { host: { type: 'string', required: true }, port: { type: 'number' } },
  682. },
  683. bag: { type: 'object' },
  684. } satisfies SchemaSpec
  685. expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([])
  686. expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([
  687. 'missing required property "config.host"',
  688. '"bag" must be an object',
  689. ])
  690. })
  691. it('recurses into array items (and an array without items only type-checks)', () => {
  692. const spec = {
  693. tags: { type: 'array', items: { type: 'string' } },
  694. raw: { type: 'array' },
  695. } satisfies SchemaSpec
  696. expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([])
  697. expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string'])
  698. // a non-array value for an array-typed prop
  699. expect(validateArgs(spec, { tags: 'nope' })).toEqual(['"tags" must be an array'])
  700. })
  701. it('validates arrays of objects element-wise', () => {
  702. const spec = {
  703. servers: {
  704. type: 'array',
  705. items: { type: 'object', properties: { host: { type: 'string', required: true } } },
  706. },
  707. } satisfies SchemaSpec
  708. expect(validateArgs(spec, { servers: [{ host: 'a' }, {}] })).toEqual([
  709. 'missing required property "servers[1].host"',
  710. ])
  711. })
  712. })
  713. describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
  714. it('returns an isError result with the violations when the model sends bad args', async () => {
  715. const ctx = await setup()
  716. ctx.tools.register(defineTool({
  717. name: 'reader',
  718. description: 'reads a path',
  719. parameters: { path: { type: 'string', required: true } },
  720. async execute(args) {
  721. return [{ type: 'text', text: args.path }]
  722. },
  723. }))
  724. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
  725. expect(result.isError).toBe(true)
  726. expect(result.content[0]).toMatchObject({
  727. text: 'Error: invalid arguments: missing required property "path"',
  728. })
  729. })
  730. it('runs execute normally when args are valid', async () => {
  731. const ctx = await setup()
  732. ctx.tools.register(defineTool({
  733. name: 'reader',
  734. description: 'reads a path',
  735. parameters: { path: { type: 'string', required: true } },
  736. async execute(args) {
  737. return [{ type: 'text', text: `read ${args.path}` }]
  738. },
  739. }))
  740. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
  741. expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'read /x' }], isError: false })
  742. })
  743. it('ToolArgsError carries a stable code and the violation list', () => {
  744. const err = new ToolArgsError(['missing required property "a"', '"b" must be a number'])
  745. expect(err).toBeInstanceOf(Error)
  746. expect(err.name).toBe('ToolArgsError')
  747. expect(err.code).toBe('INVALID_ARGS')
  748. expect(err.violations).toEqual(['missing required property "a"', '"b" must be a number'])
  749. expect(err.message).toBe('invalid arguments: missing required property "a"; "b" must be a number')
  750. })
  751. it('a schema-invalid call surfaces the structured error on the result', async () => {
  752. const ctx = await setup()
  753. ctx.tools.register(defineTool({
  754. name: 'reader',
  755. description: 'reads a path',
  756. parameters: { path: { type: 'string', required: true } },
  757. async execute(args) {
  758. return [{ type: 'text', text: args.path }]
  759. },
  760. }))
  761. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
  762. expect(result.isError).toBe(true)
  763. expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' })
  764. })
  765. it('a tool throwing a HarnessError surfaces its name and code', async () => {
  766. const { HarnessError } = await import('@deepseek-ai/dsh-llm')
  767. const ctx = await setup()
  768. ctx.tools.register({
  769. ...echoTool,
  770. name: 'coded',
  771. async execute() {
  772. throw new HarnessError('disk full', 'ENOSPC')
  773. },
  774. })
  775. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} })
  776. expect(result.isError).toBe(true)
  777. expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' })
  778. expect(result.content[0]).toMatchObject({ text: 'Error: disk full' })
  779. })
  780. it('a non-HarnessError throw has no structured error (only the text)', async () => {
  781. const ctx = await setup()
  782. ctx.tools.register({
  783. ...echoTool,
  784. name: 'plain',
  785. async execute() {
  786. throw new Error('just a message')
  787. },
  788. })
  789. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} })
  790. expect(result.isError).toBe(true)
  791. expect(result.error).toBeUndefined()
  792. expect(result.content[0]).toMatchObject({ text: 'Error: just a message' })
  793. })
  794. it('raw-registered tools are NOT validated by defineTool (MCP keeps its own)', async () => {
  795. const ctx = await setup()
  796. // A raw ToolDefinition: no defineTool wrapping, so no validateArgs guard.
  797. ctx.tools.register({
  798. name: 'raw',
  799. description: 'raw tool',
  800. parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
  801. async execute(args: unknown) {
  802. return [{ type: 'text', text: typeof args }]
  803. },
  804. })
  805. // Missing the "required" path — but raw tools validate their own input, so
  806. // this reaches execute rather than being rejected by the harness.
  807. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
  808. expect(result.isError).toBe(false)
  809. })
  810. })
  811. describe('defineTool presentation (presentCall / presentResult)', () => {
  812. it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
  813. const tool = defineTool({
  814. name: 'demo',
  815. description: 'demo',
  816. parameters: { path: { type: 'string', required: true }, n: { type: 'number' } },
  817. async execute() { return [{ type: 'text', text: 'ok' }] },
  818. presentCall(args) {
  819. // args is typed { path: string; n?: number } — zero casts.
  820. expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>()
  821. return { title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
  822. },
  823. presentResult(args, result) {
  824. return { title: `Opened ${args.path}`, content: result.content }
  825. },
  826. })
  827. expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ title: 'Open /a', kind: 'read', rawInput: '/a' })
  828. expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false }))
  829. .toEqual({ title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
  830. })
  831. it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
  832. const tool = defineTool({
  833. name: 'plain',
  834. description: 'plain',
  835. parameters: { x: { type: 'string', required: true } },
  836. async execute() { return [] },
  837. })
  838. expect(typeof tool.presentCall).toBe('undefined')
  839. expect(typeof tool.presentResult).toBe('undefined')
  840. })
  841. it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => {
  842. const tool = defineTool({
  843. name: 'demo',
  844. description: 'demo',
  845. parameters: { path: { type: 'string', required: true } },
  846. async execute() { return [] },
  847. presentCall: args => ({ title: args.path }),
  848. presentResult: (args, result) => ({ title: args.path, content: result.content }),
  849. })
  850. // Unlike execute (which throws ToolArgsError on a mismatch), the display
  851. // methods soft-validate and fall back to undefined so a UI never crashes
  852. // replaying an old/foreign log entry. The ToolDefinition methods take
  853. // `unknown`, so malformed shapes pass without a cast.
  854. expect(tool.presentCall?.({})).toBeUndefined()
  855. expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
  856. })
  857. })