tools.spec.ts 47 KB

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