tools.spec.ts 52 KB

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