tools.spec.ts 57 KB

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