tools.spec.ts 41 KB

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