tools.spec.ts 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060
  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 PreToolDecision, type PostToolDecision,
  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 a tools/pre-execute listener deny a call (permission pattern)', async () => {
  103. const ctx = await setup()
  104. ctx.tools.register(echoTool)
  105. ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
  106. if (exec.name === 'echo') return { kind: 'deny', reason: 'denied by policy' }
  107. return next()
  108. })
  109. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  110. expect(result.isError).toBe(true)
  111. expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
  112. })
  113. it('an ask decision degrades to deny until the permission system lands', async () => {
  114. const ctx = await setup()
  115. ctx.tools.register(echoTool)
  116. ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
  117. ({ kind: 'ask', reason: 'needs approval' }))
  118. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  119. expect(result.isError).toBe(true)
  120. expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' })
  121. })
  122. it('an ask decision with no reason degrades to deny with a default message', async () => {
  123. const ctx = await setup()
  124. ctx.tools.register(echoTool)
  125. ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
  126. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  127. expect(result.isError).toBe(true)
  128. expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' })
  129. })
  130. it('a tools/post-execute listener can replace the result content (accept) ', async () => {
  131. const ctx = await setup()
  132. ctx.tools.register(echoTool)
  133. ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
  134. ({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] }))
  135. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  136. expect(result.isError).toBe(false)
  137. expect(result.content[0]).toMatchObject({ text: 'rewritten' })
  138. })
  139. it('a tools/post-execute block turns the call into an isError with corrective feedback', async () => {
  140. const ctx = await setup()
  141. ctx.tools.register(echoTool)
  142. ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
  143. ({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] }))
  144. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  145. expect(result.isError).toBe(true)
  146. expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
  147. })
  148. it('a block decision can ALSO attach additionalContext', async () => {
  149. const ctx = await setup()
  150. ctx.tools.register(echoTool)
  151. ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
  152. ({
  153. kind: 'block',
  154. feedback: [{ type: 'text', text: 'rejected' }],
  155. additionalContext: { content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } },
  156. }))
  157. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  158. expect(result.isError).toBe(true)
  159. expect(result.content[0]).toMatchObject({ text: 'rejected' })
  160. expect(result.additionalContext).toMatchObject({ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } })
  161. })
  162. it('a post-execute additionalContext rides on the result for the loop to buffer', async () => {
  163. const ctx = await setup()
  164. ctx.tools.register(echoTool)
  165. ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
  166. ({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } } }))
  167. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  168. expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
  169. })
  170. it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => {
  171. // The decision is the ONLY sanctioned channel to change the outcome. A
  172. // listener that reaches in and mutates the passed result reference (flipping
  173. // isError, rewriting callId, attaching a bogus error) must NOT affect what
  174. // execute() returns — the registry snapshots the authoritative fields before
  175. // the waterfall and rebuilds from the snapshot + decision.
  176. const ctx = await setup()
  177. ctx.tools.register(echoTool)
  178. ctx.on('tools/post-execute', async (_exec, result, next) => {
  179. const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] }
  180. mutable.callId = 'hijacked'
  181. mutable.isError = true
  182. mutable.error = { name: 'Evil', code: 'EVIL' }
  183. mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
  184. return next() // delegate to the default accept — no decision-level override
  185. })
  186. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  187. expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
  188. expect(result.isError).toBe(false) // the real (successful) dispatch outcome
  189. expect(result.error).toBeUndefined() // no listener-injected error
  190. expect(result.content).toHaveLength(1) // the in-place push did not leak in
  191. expect(result.content[0]).toMatchObject({ text: 'hi' })
  192. expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
  193. })
  194. it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
  195. const ctx = await setup()
  196. ctx.tools.register(echoTool)
  197. const order: string[] = []
  198. ctx.on('tools/pre-execute', async (_exec, next) => {
  199. order.push('pre:before')
  200. const decision = await next()
  201. order.push('pre:after')
  202. return decision
  203. })
  204. ctx.on('tools/post-execute', async (_exec, _result, next) => {
  205. order.push('post:before')
  206. const decision = await next()
  207. order.push('post:after')
  208. return decision
  209. })
  210. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
  211. expect(result.isError).toBe(false)
  212. // pre runs fully (gate) before dispatch, then post runs over the result.
  213. expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
  214. })
  215. it('returns an isError result when a tools/pre-execute listener throws', async () => {
  216. const ctx = await setup()
  217. ctx.tools.register(echoTool)
  218. ctx.on('tools/pre-execute', async () => {
  219. throw new Error('permission hook broke')
  220. })
  221. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  222. expect(result).toEqual({
  223. callId: CallId('c1'),
  224. content: [{ type: 'text', text: 'Error: permission hook broke' }],
  225. isError: true,
  226. })
  227. })
  228. it('returns an isError result when a tools/post-execute listener throws', async () => {
  229. const ctx = await setup()
  230. ctx.tools.register(echoTool)
  231. ctx.on('tools/post-execute', async () => {
  232. throw new Error('post hook broke')
  233. })
  234. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  235. expect(result).toEqual({
  236. callId: CallId('c1'),
  237. content: [{ type: 'text', text: 'Error: post hook broke' }],
  238. isError: true,
  239. })
  240. })
  241. it('preserves structured error info when a tools/pre-execute listener throws HarnessError', async () => {
  242. const ctx = await setup()
  243. ctx.tools.register(echoTool)
  244. ctx.on('tools/pre-execute', async () => {
  245. throw new HarnessError('denied', 'DENIED')
  246. })
  247. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  248. expect(result).toMatchObject({
  249. callId: CallId('c1'),
  250. isError: true,
  251. error: { name: 'HarnessError', code: 'DENIED' },
  252. })
  253. })
  254. it('schemas() snapshots tool schemas instead of exposing registry objects', async () => {
  255. const ctx = await setup()
  256. ctx.tools.register(echoTool)
  257. const first = ctx.tools.schemas()
  258. const firstParameters = first[0]!.parameters as { properties: Record<string, unknown> }
  259. firstParameters.properties['mutated'] = { type: 'string' }
  260. first[0]!.description = 'mutated'
  261. expect(ctx.tools.schemas()).toEqual([{
  262. name: 'echo',
  263. description: 'echo arguments back',
  264. parameters: { type: 'object', properties: { text: { type: 'string' } } },
  265. }])
  266. })
  267. it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
  268. const ctx = await setup()
  269. ctx.tools.register(echoTool)
  270. expect(() => ctx.tools.register(echoTool)).toThrow('already registered')
  271. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  272. inner.tools.register({ ...echoTool, name: 'scoped' })
  273. }, { inject: ['tools'] }))
  274. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'scoped'])
  275. await fiber.dispose()
  276. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
  277. })
  278. it('returns a callable disposer from register() that unregisters the tool', async () => {
  279. const ctx = await setup()
  280. ctx.tools.register(echoTool)
  281. // Register a second tool and call its returned disposer directly
  282. const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' })
  283. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable'])
  284. dispose()
  285. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
  286. })
  287. it('rolls back the tool entry when a tools/change listener throws (P1-1)', async () => {
  288. const ctx = await setup()
  289. let threw = false
  290. ctx.on('tools/change', () => {
  291. if (!threw) { threw = true; throw new Error('boom change listener') }
  292. })
  293. // The throwing emit must roll the entry back, not leak it.
  294. expect(() => ctx.tools.register(echoTool)).toThrow('boom change listener')
  295. expect(ctx.tools.get('echo')).toBeUndefined() // rolled back, not leaked
  296. expect(ctx.tools.schemas()).toHaveLength(0)
  297. // A subsequent listener-free register of the SAME name succeeds and is
  298. // exposed exactly once (the duplicate-name check is not wedged).
  299. const dispose = ctx.tools.register(echoTool)
  300. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
  301. dispose()
  302. expect(ctx.tools.get('echo')).toBeUndefined()
  303. })
  304. })
  305. describe('defineTool / schema DSL', () => {
  306. it('converts SchemaSpec to standard JSON Schema with required array', () => {
  307. const spec = {
  308. path: { type: 'string', required: true, description: 'Absolute path' },
  309. offset: { type: 'number' },
  310. limit: { type: 'number', description: 'Max lines' },
  311. } satisfies SchemaSpec
  312. const jsonSchema = schemaSpecToJsonSchema(spec)
  313. expect(jsonSchema).toEqual({
  314. type: 'object',
  315. properties: {
  316. path: { type: 'string', description: 'Absolute path' },
  317. offset: { type: 'number' },
  318. limit: { type: 'number', description: 'Max lines' },
  319. },
  320. required: ['path'],
  321. })
  322. })
  323. it('handles empty spec (no properties, no required)', () => {
  324. expect(schemaSpecToJsonSchema({})).toEqual({
  325. type: 'object',
  326. properties: {},
  327. })
  328. })
  329. it('handles nested object spec', () => {
  330. const spec = {
  331. config: {
  332. type: 'object',
  333. required: true,
  334. properties: {
  335. host: { type: 'string', required: true },
  336. port: { type: 'number' },
  337. },
  338. },
  339. } satisfies SchemaSpec
  340. const jsonSchema = schemaSpecToJsonSchema(spec)
  341. expect(jsonSchema).toEqual({
  342. type: 'object',
  343. properties: {
  344. config: {
  345. type: 'object',
  346. properties: {
  347. host: { type: 'string' },
  348. port: { type: 'number' },
  349. },
  350. required: ['host'],
  351. },
  352. },
  353. required: ['config'],
  354. })
  355. })
  356. it('defineTool returns a valid ToolDefinition with typed execute', async () => {
  357. const ctx = await setup()
  358. const tool = defineTool({
  359. name: 'typed-echo',
  360. description: 'A typed echo tool',
  361. parameters: {
  362. text: { type: 'string', required: true },
  363. uppercase: { type: 'boolean' },
  364. },
  365. async execute(args) {
  366. // args is typed: { text: string; uppercase?: boolean }
  367. const result = args.uppercase ? args.text.toUpperCase() : args.text
  368. return [{ type: 'text', text: result }]
  369. },
  370. })
  371. ctx.tools.register(tool)
  372. expect(ctx.tools.schemas()).toEqual([{
  373. name: 'typed-echo',
  374. description: 'A typed echo tool',
  375. parameters: {
  376. type: 'object',
  377. properties: {
  378. text: { type: 'string' },
  379. uppercase: { type: 'boolean' },
  380. },
  381. required: ['text'],
  382. },
  383. }])
  384. const result = await ctx.tools.execute({
  385. callId: CallId('c1'),
  386. name: 'typed-echo',
  387. arguments: { text: 'hello', uppercase: true },
  388. })
  389. expect(result.isError).toBe(false)
  390. expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
  391. })
  392. it('type-level: InferArgs maps required properties to non-optional', () => {
  393. // Compile-time check: if this compiles, InferArgs is correct.
  394. // args.a is string (required), args.b is number|undefined (optional).
  395. const tool = defineTool({
  396. name: 'type-check',
  397. description: '',
  398. parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
  399. async execute(args) {
  400. // Verify types at runtime via typeof
  401. expect(typeof args.a).toBe('string')
  402. // args.b should be undefined when not provided
  403. void args
  404. return [{ type: 'text', text: args.a }]
  405. },
  406. })
  407. void tool
  408. })
  409. it('registry round-trips a defineTool definition (register→schemas→execute)', async () => {
  410. const ctx = await setup()
  411. ctx.tools.register(defineTool({
  412. name: 'roundtrip',
  413. description: 'Round-trip test',
  414. parameters: {
  415. req: { type: 'string', required: true },
  416. opt: { type: 'number', description: 'Optional number' },
  417. },
  418. async execute(args) {
  419. return [{ type: 'text', text: `${args.req}:${args.opt ?? 'none'}` }]
  420. },
  421. }))
  422. // Schema round-trip: schemas() returns standard JSON Schema
  423. const schemas = ctx.tools.schemas()
  424. expect(schemas).toHaveLength(1)
  425. expect(schemas[0]!.parameters).toEqual({
  426. type: 'object',
  427. properties: {
  428. req: { type: 'string' },
  429. opt: { type: 'number', description: 'Optional number' },
  430. },
  431. required: ['req'],
  432. })
  433. // Execution round-trip
  434. const result = await ctx.tools.execute({
  435. callId: CallId('c1'),
  436. name: 'roundtrip',
  437. arguments: { req: 'hello' },
  438. })
  439. expect(result.isError).toBe(false)
  440. expect(result.content).toEqual([{ type: 'text', text: 'hello:none' }])
  441. })
  442. it('still accepts raw JSON-Schema ToolDefinition directly (MCP interop)', async () => {
  443. const ctx = await setup()
  444. ctx.tools.register({
  445. name: 'raw-tool',
  446. description: 'Raw JSON Schema tool (like an MCP adapter would register)',
  447. parameters: {
  448. type: 'object',
  449. properties: { path: { type: 'string' } },
  450. required: ['path'],
  451. },
  452. async execute(args: unknown) {
  453. const p = args as { path: string }
  454. return [{ type: 'text', text: p.path }]
  455. },
  456. })
  457. const schemas = ctx.tools.schemas()
  458. expect(schemas[0]!.parameters).toEqual({
  459. type: 'object',
  460. properties: { path: { type: 'string' } },
  461. required: ['path'],
  462. })
  463. const result = await ctx.tools.execute({
  464. callId: CallId('c1'),
  465. name: 'raw-tool',
  466. arguments: { path: '/tmp' },
  467. })
  468. expect(result.isError).toBe(false)
  469. expect(result.content).toEqual([{ type: 'text', text: '/tmp' }])
  470. })
  471. })
  472. describe('schema DSL edge cases', () => {
  473. it('emits enum values in JSON Schema property', () => {
  474. const spec = {
  475. color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' },
  476. } satisfies SchemaSpec
  477. const jsonSchema = schemaSpecToJsonSchema(spec)
  478. expect(jsonSchema.properties['color']).toMatchObject({
  479. type: 'string',
  480. enum: ['red', 'green', 'blue'],
  481. description: 'Color choice',
  482. })
  483. })
  484. it('emits default value in JSON Schema property', () => {
  485. const spec = {
  486. limit: { type: 'number', default: 25 },
  487. } satisfies SchemaSpec
  488. const jsonSchema = schemaSpecToJsonSchema(spec)
  489. expect(jsonSchema.properties['limit']).toMatchObject({
  490. type: 'number',
  491. default: 25,
  492. })
  493. })
  494. it('handles array items without nested properties (plain type array)', () => {
  495. const spec = {
  496. tags: { type: 'array', items: { type: 'string' } },
  497. } satisfies SchemaSpec
  498. const jsonSchema = schemaSpecToJsonSchema(spec)
  499. expect(jsonSchema.properties['tags']).toEqual({
  500. type: 'array',
  501. items: { type: 'string' },
  502. })
  503. })
  504. it('defineTool passes through strict flag when set to true', () => {
  505. const tool = defineTool({
  506. name: 'strict-tool',
  507. description: 'A strict tool',
  508. parameters: { input: { type: 'string' } },
  509. strict: true,
  510. async execute(args) {
  511. return [{ type: 'text' as const, text: args.input ?? '' }]
  512. },
  513. })
  514. expect(tool.strict).toBe(true)
  515. })
  516. it('defineTool omits strict when not provided', () => {
  517. const tool = defineTool({
  518. name: 'non-strict-tool',
  519. description: 'A non-strict tool',
  520. parameters: { input: { type: 'string' } },
  521. async execute(args) {
  522. return [{ type: 'text' as const, text: args.input ?? '' }]
  523. },
  524. })
  525. expect('strict' in tool).toBe(false)
  526. })
  527. it('defineTool strict=false is included', () => {
  528. const tool = defineTool({
  529. name: 'explicitly-non-strict',
  530. description: 'Explicitly non-strict',
  531. parameters: { input: { type: 'string' } },
  532. strict: false,
  533. async execute(args) {
  534. return [{ type: 'text' as const, text: args.input ?? '' }]
  535. },
  536. })
  537. expect(tool.strict).toBe(false)
  538. })
  539. it('handles enum and default together in one property', () => {
  540. const spec = {
  541. level: { type: 'string', enum: ['low', 'high'], default: 'low' },
  542. } satisfies SchemaSpec
  543. const jsonSchema = schemaSpecToJsonSchema(spec)
  544. expect(jsonSchema.properties['level']).toMatchObject({
  545. type: 'string',
  546. enum: ['low', 'high'],
  547. default: 'low',
  548. })
  549. })
  550. it('omits description, enum, default keys when not specified', () => {
  551. const spec = {
  552. bare: { type: 'string' },
  553. } satisfies SchemaSpec
  554. const jsonSchema = schemaSpecToJsonSchema(spec)
  555. const prop = jsonSchema.properties['bare'] as Record<string, unknown>
  556. expect(prop).toEqual({ type: 'string' })
  557. expect('description' in prop).toBe(false)
  558. expect('enum' in prop).toBe(false)
  559. expect('default' in prop).toBe(false)
  560. })
  561. it('handles array with no items (items omitted)', () => {
  562. const spec = {
  563. raw: { type: 'array' },
  564. } satisfies SchemaSpec
  565. const jsonSchema = schemaSpecToJsonSchema(spec)
  566. expect(jsonSchema.properties['raw']).toEqual({
  567. type: 'array',
  568. })
  569. })
  570. it('handles nested object with all-optional properties (no required array)', () => {
  571. const spec = {
  572. config: {
  573. type: 'object',
  574. properties: {
  575. host: { type: 'string' },
  576. port: { type: 'number' },
  577. },
  578. },
  579. } satisfies SchemaSpec
  580. const jsonSchema = schemaSpecToJsonSchema(spec)
  581. expect(jsonSchema.properties['config']).toMatchObject({
  582. type: 'object',
  583. properties: {
  584. host: { type: 'string' },
  585. port: { type: 'number' },
  586. },
  587. })
  588. // no 'required' key in the nested object because nothing is required
  589. const config = jsonSchema.properties['config'] as Record<string, unknown>
  590. expect('required' in config).toBe(false)
  591. })
  592. })
  593. describe('schema DSL regressions (Codex review round 2)', () => {
  594. it('InferArgs makes non-required keys genuinely optional (omittable)', () => {
  595. type Args = InferArgs<{
  596. path: { type: 'string'; required: true }
  597. limit: { type: 'number' }
  598. }>
  599. expectTypeOf<Args>().toEqualTypeOf<{ path: string; limit?: number }>()
  600. // omitting the optional key is assignable — the actual regression
  601. const omitted: Args = { path: '/tmp' }
  602. expect(omitted.limit).toBeUndefined()
  603. })
  604. it('InferArgs recurses into array items, including arrays of objects', () => {
  605. type Args = InferArgs<{
  606. names: { type: 'array'; required: true; items: { type: 'string' } }
  607. servers: {
  608. type: 'array'
  609. items: {
  610. type: 'object'
  611. properties: {
  612. host: { type: 'string'; required: true }
  613. port: { type: 'number' }
  614. }
  615. }
  616. }
  617. }>
  618. expectTypeOf<Args>().toEqualTypeOf<{
  619. names: string[]
  620. servers?: { host: string; port?: number }[]
  621. }>()
  622. })
  623. it('runtime JSON Schema matches the array-of-objects inference', () => {
  624. const spec = {
  625. servers: {
  626. type: 'array',
  627. items: {
  628. type: 'object',
  629. properties: {
  630. host: { type: 'string', required: true },
  631. port: { type: 'number' },
  632. },
  633. },
  634. },
  635. } satisfies SchemaSpec
  636. expect(schemaSpecToJsonSchema(spec)).toEqual({
  637. type: 'object',
  638. properties: {
  639. servers: {
  640. type: 'array',
  641. items: {
  642. type: 'object',
  643. properties: {
  644. host: { type: 'string' },
  645. port: { type: 'number' },
  646. },
  647. required: ['host'],
  648. },
  649. },
  650. },
  651. })
  652. })
  653. it('reports messages from non-Error throws (throw { message })', async () => {
  654. const ctx = await setup()
  655. ctx.tools.register({
  656. ...echoTool,
  657. name: 'object-thrower',
  658. async execute() {
  659. // testing non-Error throws on purpose
  660. throw { message: 'denied by object' }
  661. },
  662. })
  663. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-thrower', arguments: {} })
  664. expect(result.isError).toBe(true)
  665. expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' })
  666. })
  667. it('reports messages from throws of non-objects (throw "string")', async () => {
  668. const ctx = await setup()
  669. ctx.tools.register({
  670. ...echoTool,
  671. name: 'string-thrower',
  672. async execute() {
  673. // testing primitive throws on purpose
  674. throw 'kaboom'
  675. },
  676. })
  677. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'string-thrower', arguments: {} })
  678. expect(result.isError).toBe(true)
  679. expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
  680. })
  681. it('reports messages from throws of objects without message property', async () => {
  682. const ctx = await setup()
  683. ctx.tools.register({
  684. ...echoTool,
  685. name: 'object-no-message',
  686. async execute() {
  687. // testing object throw without .message
  688. throw { code: 500 }
  689. },
  690. })
  691. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-no-message', arguments: {} })
  692. expect(result.isError).toBe(true)
  693. const firstContent = result.content[0]!
  694. expect(firstContent.type).toBe('text')
  695. if (firstContent.type === 'text') {
  696. expect(firstContent.text).toBe('Error: [object Object]')
  697. }
  698. })
  699. })
  700. describe('ToolRegistry.get', () => {
  701. it('get() returns the registered tool definition', async () => {
  702. const ctx = await setup()
  703. ctx.tools.register(echoTool)
  704. const tool = ctx.tools.get('echo')
  705. expect(tool).toBeDefined()
  706. expect(tool!.name).toBe('echo')
  707. })
  708. it('get() returns undefined for unknown tool names', async () => {
  709. const ctx = await setup()
  710. expect(ctx.tools.get('nope')).toBeUndefined()
  711. })
  712. })
  713. describe('validateArgs (the runtime-validation RFC, part 1)', () => {
  714. it('returns [] for valid args and is total over malformed input', () => {
  715. const spec = {
  716. path: { type: 'string', required: true },
  717. limit: { type: 'number' },
  718. } satisfies SchemaSpec
  719. expect(validateArgs(spec, { path: '/tmp' })).toEqual([])
  720. expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([])
  721. // never throws regardless of shape
  722. expect(validateArgs(spec, null)).toHaveLength(1)
  723. expect(validateArgs(spec, 'nope')).toHaveLength(1)
  724. expect(validateArgs(spec, [])).toHaveLength(1)
  725. })
  726. it('flags a missing required key and a required key present as undefined', () => {
  727. const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
  728. expect(validateArgs(spec, {})).toEqual(['missing required property "path"'])
  729. expect(validateArgs(spec, { path: undefined })).toEqual(['missing required property "path"'])
  730. })
  731. it('allows extra keys (no additionalProperties:false) and omitted optionals', () => {
  732. const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
  733. expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([])
  734. })
  735. it('does not apply defaults (validation only)', () => {
  736. const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec
  737. // absent optional is valid, and validation does not synthesize the default
  738. expect(validateArgs(spec, {})).toEqual([])
  739. })
  740. it('type-checks primitives', () => {
  741. const spec = {
  742. s: { type: 'string' },
  743. n: { type: 'number' },
  744. b: { type: 'boolean' },
  745. } satisfies SchemaSpec
  746. expect(validateArgs(spec, { s: 1 })).toEqual(['"s" must be a string'])
  747. expect(validateArgs(spec, { n: 'x' })).toEqual(['"n" must be a number'])
  748. expect(validateArgs(spec, { b: 'x' })).toEqual(['"b" must be a boolean'])
  749. })
  750. it('checks enum membership', () => {
  751. const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies SchemaSpec
  752. expect(validateArgs(spec, { color: 'red' })).toEqual([])
  753. expect(validateArgs(spec, { color: 'blue' })).toEqual(['"color" must be one of ["red","green"]'])
  754. })
  755. it('checks enum uniformly with the converter (enum on a non-string prop)', () => {
  756. // The converter emits `enum` regardless of type; the validator must agree.
  757. // `enum` is string[], so a number value can never be a member.
  758. const spec = { n: { type: 'number', enum: ['1', '2'] } } as unknown as SchemaSpec
  759. expect(validateArgs(spec, { n: 1 })).toEqual(['"n" must be one of ["1","2"]'])
  760. })
  761. it('rejects an unknown SchemaType at runtime (assertNever guard)', () => {
  762. const spec = { x: { type: 'weird' } } as unknown as SchemaSpec
  763. expect(() => validateArgs(spec, { x: 1 })).toThrow(/unreachable variant.*validateArgs/)
  764. })
  765. it('recurses into nested objects (and an object without properties only type-checks)', () => {
  766. const spec = {
  767. config: {
  768. type: 'object',
  769. required: true,
  770. properties: { host: { type: 'string', required: true }, port: { type: 'number' } },
  771. },
  772. bag: { type: 'object' },
  773. } satisfies SchemaSpec
  774. expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([])
  775. expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([
  776. 'missing required property "config.host"',
  777. '"bag" must be an object',
  778. ])
  779. })
  780. it('recurses into array items (and an array without items only type-checks)', () => {
  781. const spec = {
  782. tags: { type: 'array', items: { type: 'string' } },
  783. raw: { type: 'array' },
  784. } satisfies SchemaSpec
  785. expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([])
  786. expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string'])
  787. // a non-array value for an array-typed prop
  788. expect(validateArgs(spec, { tags: 'nope' })).toEqual(['"tags" must be an array'])
  789. })
  790. it('validates arrays of objects element-wise', () => {
  791. const spec = {
  792. servers: {
  793. type: 'array',
  794. items: { type: 'object', properties: { host: { type: 'string', required: true } } },
  795. },
  796. } satisfies SchemaSpec
  797. expect(validateArgs(spec, { servers: [{ host: 'a' }, {}] })).toEqual([
  798. 'missing required property "servers[1].host"',
  799. ])
  800. })
  801. })
  802. describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
  803. it('returns an isError result with the violations when the model sends bad args', async () => {
  804. const ctx = await setup()
  805. ctx.tools.register(defineTool({
  806. name: 'reader',
  807. description: 'reads a path',
  808. parameters: { path: { type: 'string', required: true } },
  809. async execute(args) {
  810. return [{ type: 'text', text: args.path }]
  811. },
  812. }))
  813. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
  814. expect(result.isError).toBe(true)
  815. expect(result.content[0]).toMatchObject({
  816. text: 'Error: invalid arguments: missing required property "path"',
  817. })
  818. })
  819. it('runs execute normally when args are valid', async () => {
  820. const ctx = await setup()
  821. ctx.tools.register(defineTool({
  822. name: 'reader',
  823. description: 'reads a path',
  824. parameters: { path: { type: 'string', required: true } },
  825. async execute(args) {
  826. return [{ type: 'text', text: `read ${args.path}` }]
  827. },
  828. }))
  829. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
  830. expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'read /x' }], isError: false })
  831. })
  832. it('ToolArgsError carries a stable code and the violation list', () => {
  833. const err = new ToolArgsError(['missing required property "a"', '"b" must be a number'])
  834. expect(err).toBeInstanceOf(Error)
  835. expect(err.name).toBe('ToolArgsError')
  836. expect(err.code).toBe('INVALID_ARGS')
  837. expect(err.violations).toEqual(['missing required property "a"', '"b" must be a number'])
  838. expect(err.message).toBe('invalid arguments: missing required property "a"; "b" must be a number')
  839. })
  840. it('a schema-invalid call surfaces the structured error on the result', async () => {
  841. const ctx = await setup()
  842. ctx.tools.register(defineTool({
  843. name: 'reader',
  844. description: 'reads a path',
  845. parameters: { path: { type: 'string', required: true } },
  846. async execute(args) {
  847. return [{ type: 'text', text: args.path }]
  848. },
  849. }))
  850. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
  851. expect(result.isError).toBe(true)
  852. expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' })
  853. })
  854. it('a tool throwing a HarnessError surfaces its name and code', async () => {
  855. const { HarnessError } = await import('@deepseek-ai/dsh-llm')
  856. const ctx = await setup()
  857. ctx.tools.register({
  858. ...echoTool,
  859. name: 'coded',
  860. async execute() {
  861. throw new HarnessError('disk full', 'ENOSPC')
  862. },
  863. })
  864. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} })
  865. expect(result.isError).toBe(true)
  866. expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' })
  867. expect(result.content[0]).toMatchObject({ text: 'Error: disk full' })
  868. })
  869. it('a non-HarnessError throw has no structured error (only the text)', async () => {
  870. const ctx = await setup()
  871. ctx.tools.register({
  872. ...echoTool,
  873. name: 'plain',
  874. async execute() {
  875. throw new Error('just a message')
  876. },
  877. })
  878. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} })
  879. expect(result.isError).toBe(true)
  880. expect(result.error).toBeUndefined()
  881. expect(result.content[0]).toMatchObject({ text: 'Error: just a message' })
  882. })
  883. it('raw-registered tools are NOT validated by defineTool (MCP keeps its own)', async () => {
  884. const ctx = await setup()
  885. // A raw ToolDefinition: no defineTool wrapping, so no validateArgs guard.
  886. ctx.tools.register({
  887. name: 'raw',
  888. description: 'raw tool',
  889. parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
  890. async execute(args: unknown) {
  891. return [{ type: 'text', text: typeof args }]
  892. },
  893. })
  894. // Missing the "required" path — but raw tools validate their own input, so
  895. // this reaches execute rather than being rejected by the harness.
  896. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
  897. expect(result.isError).toBe(false)
  898. })
  899. })
  900. describe('defineTool presentation (presentCall / presentResult)', () => {
  901. it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
  902. const tool = defineTool({
  903. name: 'demo',
  904. description: 'demo',
  905. parameters: { path: { type: 'string', required: true }, n: { type: 'number' } },
  906. async execute() { return [{ type: 'text', text: 'ok' }] },
  907. presentCall(args) {
  908. // args is typed { path: string; n?: number } — zero casts.
  909. expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>()
  910. return { title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
  911. },
  912. presentResult(args, result) {
  913. return { title: `Opened ${args.path}`, content: result.content }
  914. },
  915. })
  916. expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ title: 'Open /a', kind: 'read', rawInput: '/a' })
  917. expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false }))
  918. .toEqual({ title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
  919. })
  920. it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
  921. const tool = defineTool({
  922. name: 'plain',
  923. description: 'plain',
  924. parameters: { x: { type: 'string', required: true } },
  925. async execute() { return [] },
  926. })
  927. expect(typeof tool.presentCall).toBe('undefined')
  928. expect(typeof tool.presentResult).toBe('undefined')
  929. })
  930. it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => {
  931. const tool = defineTool({
  932. name: 'demo',
  933. description: 'demo',
  934. parameters: { path: { type: 'string', required: true } },
  935. async execute() { return [] },
  936. presentCall: args => ({ title: args.path }),
  937. presentResult: (args, result) => ({ title: args.path, content: result.content }),
  938. })
  939. // Unlike execute (which throws ToolArgsError on a mismatch), the display
  940. // methods soft-validate and fall back to undefined so a UI never crashes
  941. // replaying an old/foreign log entry. The ToolDefinition methods take
  942. // `unknown`, so malformed shapes pass without a cast.
  943. expect(tool.presentCall?.({})).toBeUndefined()
  944. expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
  945. })
  946. })