tools.spec.ts 54 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392
  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 additionalContext', 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. additionalContext: { 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.additionalContext).toMatchObject({ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } })
  315. })
  316. it('a post-execute additionalContext rides 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', additionalContext: { 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.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
  323. })
  324. it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
  325. const ctx = await setup()
  326. ctx.tools.register(echoTool)
  327. const order: string[] = []
  328. ctx.on('tools/pre-execute', async (_exec, next) => {
  329. order.push('pre:before')
  330. const decision = await next()
  331. order.push('pre:after')
  332. return decision
  333. })
  334. ctx.on('tools/post-execute', async (_exec, _result, next) => {
  335. order.push('post:before')
  336. const decision = await next()
  337. order.push('post:after')
  338. return decision
  339. })
  340. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
  341. expect(result.isError).toBe(false)
  342. // pre runs fully (gate) before dispatch, then post runs over the result.
  343. expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
  344. })
  345. it('runs tools/execute after an allowed pre-execute, around dispatch, and before post-execute', async () => {
  346. const ctx = await setup()
  347. const order: string[] = []
  348. ctx.tools.register(defineTool({
  349. name: 'traced',
  350. description: 'echo',
  351. parameters: { text: { type: 'string' } },
  352. async execute(args) {
  353. order.push('dispatch')
  354. return [{ type: 'text' as const, text: args.text ?? '' }]
  355. },
  356. }))
  357. ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() })
  358. ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
  359. order.push('execute:before')
  360. const result = await next()
  361. order.push('execute:after')
  362. return result
  363. })
  364. ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
  365. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
  366. expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
  367. // The around seam wraps dispatch; pre gates before it, post runs over its result.
  368. expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
  369. })
  370. it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => {
  371. const ctx = await setup()
  372. ctx.tools.register(echoTool)
  373. let entered = false
  374. ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'deny', reason: 'nope' }))
  375. ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
  376. entered = true
  377. return next()
  378. })
  379. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  380. expect(result.isError).toBe(true)
  381. expect(result.content[0]).toMatchObject({ text: 'Error: nope' })
  382. expect(entered).toBe(false) // a denied call never enters the around-dispatch seam
  383. })
  384. it('a thrown tool is normalized to an isError result BEFORE a tools/execute listener sees next()', async () => {
  385. const ctx = await setup()
  386. ctx.tools.register({
  387. ...echoTool,
  388. name: 'boom',
  389. async execute() { throw new HarnessError('kaboom', 'BOOM') },
  390. })
  391. let seen: { isError: boolean; error?: unknown } | undefined
  392. ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
  393. const result = await next()
  394. // The base next() IS dispatch-with-normalization: the wrapper sees the
  395. // normalized isError result, never a raw throw from the tool body.
  396. seen = { isError: result.isError, error: result.error }
  397. return result
  398. })
  399. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
  400. expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } })
  401. expect(result.isError).toBe(true)
  402. expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
  403. })
  404. it('a thrown tool normalized inside tools/execute still reaches post-execute', async () => {
  405. const ctx = await setup()
  406. ctx.tools.register({
  407. ...echoTool,
  408. name: 'boom',
  409. async execute() { throw new Error('exploded') },
  410. })
  411. let postSaw: boolean | undefined
  412. ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
  413. ctx.on('tools/post-execute', async (_exec, result, next) => {
  414. postSaw = result.isError
  415. return next()
  416. })
  417. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
  418. expect(postSaw).toBe(true) // the normalized isError still flows through post-execute
  419. expect(result.isError).toBe(true)
  420. expect(result.content[0]).toMatchObject({ text: 'Error: exploded' })
  421. })
  422. it('a tools/execute listener can replace exec.signal for the dispatched tool (deadline pattern)', async () => {
  423. const ctx = await setup()
  424. let seenSignal: AbortSignal | undefined
  425. ctx.tools.register({
  426. ...echoTool,
  427. name: 'signal-probe',
  428. async execute(_args, exec) {
  429. seenSignal = exec.signal
  430. return [{ type: 'text' as const, text: 'ok' }]
  431. },
  432. })
  433. const upstream = new AbortController().signal
  434. const replacement = new AbortController().signal
  435. ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
  436. expect(exec.signal).toBe(upstream)
  437. // Cordis next() ignores passed arguments, so a wrapper mutates exec in
  438. // place (the documented "mutate the shared object, then delegate" idiom).
  439. exec.signal = replacement
  440. return next()
  441. })
  442. await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream })
  443. expect(seenSignal).toBe(replacement) // dispatch saw the wrapper's replacement, not the upstream
  444. })
  445. it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => {
  446. const ctx = await setup()
  447. let dispatched = false
  448. ctx.tools.register({
  449. ...echoTool,
  450. name: 'never-runs',
  451. async execute() { dispatched = true; return [] },
  452. })
  453. ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
  454. ({ content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
  455. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
  456. expect(dispatched).toBe(false) // returning without next() skips core dispatch
  457. expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
  458. })
  459. it('preserves additionalContext supplied by an around-dispatch result', async () => {
  460. const ctx = await setup()
  461. ctx.tools.register(echoTool)
  462. ctx.on('tools/execute', async () => ({
  463. content: [{ type: 'text', text: 'short-circuited with context' }],
  464. isError: false,
  465. additionalContext: {
  466. content: [{ type: 'text', text: 'from around dispatch' }],
  467. source: { kind: 'plugin', plugin: 'test' },
  468. },
  469. }))
  470. const result = await ctx.tools.execute({
  471. callId: CallId('around-context'), name: 'echo', arguments: {},
  472. })
  473. expect(result.additionalContext).toEqual({
  474. content: [{ type: 'text', text: 'from around dispatch' }],
  475. source: { kind: 'plugin', plugin: 'test' },
  476. })
  477. })
  478. it('returns an isError result when a tools/execute listener throws', async () => {
  479. const ctx = await setup()
  480. ctx.tools.register(echoTool)
  481. ctx.on('tools/execute', async () => { throw new Error('wrapper broke') })
  482. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  483. expect(result).toEqual({
  484. content: [{ type: 'text', text: 'Error: wrapper broke' }],
  485. isError: true,
  486. })
  487. })
  488. it('returns an isError result when a tools/pre-execute listener throws', async () => {
  489. const ctx = await setup()
  490. ctx.tools.register(echoTool)
  491. ctx.on('tools/pre-execute', async () => {
  492. throw new Error('permission hook broke')
  493. })
  494. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  495. expect(result).toEqual({
  496. content: [{ type: 'text', text: 'Error: permission hook broke' }],
  497. isError: true,
  498. })
  499. })
  500. it('returns an isError result when a tools/post-execute listener throws', async () => {
  501. const ctx = await setup()
  502. ctx.tools.register(echoTool)
  503. ctx.on('tools/post-execute', async () => {
  504. throw new Error('post hook broke')
  505. })
  506. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  507. expect(result).toEqual({
  508. content: [{ type: 'text', text: 'Error: post hook broke' }],
  509. isError: true,
  510. })
  511. })
  512. it('preserves structured error info when a tools/pre-execute listener throws HarnessError', async () => {
  513. const ctx = await setup()
  514. ctx.tools.register(echoTool)
  515. ctx.on('tools/pre-execute', async () => {
  516. throw new HarnessError('denied', 'DENIED')
  517. })
  518. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
  519. expect(result).toMatchObject({
  520. isError: true,
  521. error: { name: 'HarnessError', code: 'DENIED' },
  522. })
  523. })
  524. it('schemas() snapshots tool schemas instead of exposing registry objects', async () => {
  525. const ctx = await setup()
  526. ctx.tools.register(echoTool)
  527. const first = ctx.tools.schemas()
  528. const firstParameters = first[0]!.parameters as { properties: Record<string, unknown> }
  529. firstParameters.properties['mutated'] = { type: 'string' }
  530. first[0]!.description = 'mutated'
  531. expect(ctx.tools.schemas()).toEqual([{
  532. name: 'echo',
  533. description: 'echo arguments back',
  534. parameters: { type: 'object', properties: { text: { type: 'string' } } },
  535. }])
  536. })
  537. it('rejects a non-positive or non-finite registration timeout', async () => {
  538. const ctx = await setup()
  539. expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 }))
  540. .toThrow('timeoutMs must be a positive finite number')
  541. expect(() => ctx.tools.register({ ...echoTool, name: 'infinite-timeout', timeoutMs: Number.POSITIVE_INFINITY }))
  542. .toThrow('timeoutMs must be a positive finite number')
  543. })
  544. it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
  545. const ctx = await setup()
  546. ctx.tools.register(echoTool)
  547. expect(() => ctx.tools.register(echoTool)).toThrow('already registered')
  548. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  549. inner.tools.register({ ...echoTool, name: 'scoped' })
  550. }, { inject: ['tools'] }))
  551. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'scoped'])
  552. await fiber.dispose()
  553. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
  554. })
  555. it('returns a callable disposer from register() that unregisters the tool', async () => {
  556. const ctx = await setup()
  557. ctx.tools.register(echoTool)
  558. // Register a second tool and call its returned disposer directly
  559. const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' })
  560. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable'])
  561. dispose()
  562. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
  563. })
  564. it('rolls back the tool entry when a tools/change listener throws (P1-1)', async () => {
  565. const ctx = await setup()
  566. let threw = false
  567. ctx.on('tools/change', () => {
  568. if (!threw) { threw = true; throw new Error('boom change listener') }
  569. })
  570. // The throwing emit must roll the entry back, not leak it.
  571. expect(() => ctx.tools.register(echoTool)).toThrow('boom change listener')
  572. expect(ctx.tools.get('echo')).toBeUndefined() // rolled back, not leaked
  573. expect(ctx.tools.schemas()).toHaveLength(0)
  574. // A subsequent listener-free register of the SAME name succeeds and is
  575. // exposed exactly once (the duplicate-name check is not wedged).
  576. const dispose = ctx.tools.register(echoTool)
  577. expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
  578. dispose()
  579. expect(ctx.tools.get('echo')).toBeUndefined()
  580. })
  581. it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => {
  582. // Registry methods return the exact Cordis effect disposer so a composite yield places
  583. // unregistration at its LIFO position. A wrapper would create a concurrent sibling; this async
  584. // probe yields during earlier teardown and would then observe the tool already removed.
  585. const ctx = await setup()
  586. const order: string[] = []
  587. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  588. inner.effect(function* () {
  589. yield () => { order.push('disposed-last') }
  590. yield inner.tools.register({ ...echoTool, name: 'nested' })
  591. order.push('registered')
  592. yield async () => {
  593. await new Promise(resolve => setTimeout(resolve, 0))
  594. order.push(inner.tools.get('nested') ? 'first: still registered' : 'first: already gone')
  595. }
  596. })
  597. }, { inject: ['tools'] }))
  598. await fiber.dispose()
  599. expect(order).toEqual(['registered', 'first: still registered', 'disposed-last'])
  600. expect(ctx.tools.get('nested')).toBeUndefined()
  601. })
  602. })
  603. describe('defineTool / schema DSL', () => {
  604. it('converts SchemaSpec to standard JSON Schema with required array', () => {
  605. const spec = {
  606. path: { type: 'string', required: true, description: 'Absolute path' },
  607. offset: { type: 'number' },
  608. limit: { type: 'number', description: 'Max lines' },
  609. } satisfies SchemaSpec
  610. const jsonSchema = schemaSpecToJsonSchema(spec)
  611. expect(jsonSchema).toEqual({
  612. type: 'object',
  613. properties: {
  614. path: { type: 'string', description: 'Absolute path' },
  615. offset: { type: 'number' },
  616. limit: { type: 'number', description: 'Max lines' },
  617. },
  618. required: ['path'],
  619. })
  620. })
  621. it('handles empty spec (no properties, no required)', () => {
  622. expect(schemaSpecToJsonSchema({})).toEqual({
  623. type: 'object',
  624. properties: {},
  625. })
  626. })
  627. it('handles nested object spec', () => {
  628. const spec = {
  629. config: {
  630. type: 'object',
  631. required: true,
  632. properties: {
  633. host: { type: 'string', required: true },
  634. port: { type: 'number' },
  635. },
  636. },
  637. } satisfies SchemaSpec
  638. const jsonSchema = schemaSpecToJsonSchema(spec)
  639. expect(jsonSchema).toEqual({
  640. type: 'object',
  641. properties: {
  642. config: {
  643. type: 'object',
  644. properties: {
  645. host: { type: 'string' },
  646. port: { type: 'number' },
  647. },
  648. required: ['host'],
  649. },
  650. },
  651. required: ['config'],
  652. })
  653. })
  654. it('defineTool returns a valid ToolDefinition with typed execute', async () => {
  655. const ctx = await setup()
  656. const tool = defineTool({
  657. name: 'typed-echo',
  658. description: 'A typed echo tool',
  659. parameters: {
  660. text: { type: 'string', required: true },
  661. uppercase: { type: 'boolean' },
  662. },
  663. async execute(args) {
  664. // args is typed: { text: string; uppercase?: boolean }
  665. const result = args.uppercase ? args.text.toUpperCase() : args.text
  666. return [{ type: 'text', text: result }]
  667. },
  668. })
  669. ctx.tools.register(tool)
  670. expect(ctx.tools.schemas()).toEqual([{
  671. name: 'typed-echo',
  672. description: 'A typed echo tool',
  673. parameters: {
  674. type: 'object',
  675. properties: {
  676. text: { type: 'string' },
  677. uppercase: { type: 'boolean' },
  678. },
  679. required: ['text'],
  680. },
  681. }])
  682. const result = await ctx.tools.execute({
  683. callId: CallId('c1'),
  684. name: 'typed-echo',
  685. arguments: { text: 'hello', uppercase: true },
  686. })
  687. expect(result.isError).toBe(false)
  688. expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
  689. })
  690. it('type-level: InferArgs maps required properties to non-optional', () => {
  691. // Compile-time check: if this compiles, InferArgs is correct.
  692. // args.a is string (required), args.b is number|undefined (optional).
  693. const tool = defineTool({
  694. name: 'type-check',
  695. description: '',
  696. parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
  697. async execute(args) {
  698. // Verify types at runtime via typeof
  699. expect(typeof args.a).toBe('string')
  700. // args.b should be undefined when not provided
  701. void args
  702. return [{ type: 'text', text: args.a }]
  703. },
  704. })
  705. void tool
  706. })
  707. it('registry round-trips a defineTool definition (register→schemas→execute)', async () => {
  708. const ctx = await setup()
  709. ctx.tools.register(defineTool({
  710. name: 'roundtrip',
  711. description: 'Round-trip test',
  712. parameters: {
  713. req: { type: 'string', required: true },
  714. opt: { type: 'number', description: 'Optional number' },
  715. },
  716. async execute(args) {
  717. return [{ type: 'text', text: `${args.req}:${args.opt ?? 'none'}` }]
  718. },
  719. }))
  720. // Schema round-trip: schemas() returns standard JSON Schema
  721. const schemas = ctx.tools.schemas()
  722. expect(schemas).toHaveLength(1)
  723. expect(schemas[0]!.parameters).toEqual({
  724. type: 'object',
  725. properties: {
  726. req: { type: 'string' },
  727. opt: { type: 'number', description: 'Optional number' },
  728. },
  729. required: ['req'],
  730. })
  731. // Execution round-trip
  732. const result = await ctx.tools.execute({
  733. callId: CallId('c1'),
  734. name: 'roundtrip',
  735. arguments: { req: 'hello' },
  736. })
  737. expect(result.isError).toBe(false)
  738. expect(result.content).toEqual([{ type: 'text', text: 'hello:none' }])
  739. })
  740. it('still accepts raw JSON-Schema ToolDefinition directly (MCP interop)', async () => {
  741. const ctx = await setup()
  742. ctx.tools.register({
  743. name: 'raw-tool',
  744. description: 'Raw JSON Schema tool (like an MCP adapter would register)',
  745. parameters: {
  746. type: 'object',
  747. properties: { path: { type: 'string' } },
  748. required: ['path'],
  749. },
  750. async execute(args: unknown) {
  751. const p = args as { path: string }
  752. return [{ type: 'text', text: p.path }]
  753. },
  754. })
  755. const schemas = ctx.tools.schemas()
  756. expect(schemas[0]!.parameters).toEqual({
  757. type: 'object',
  758. properties: { path: { type: 'string' } },
  759. required: ['path'],
  760. })
  761. const result = await ctx.tools.execute({
  762. callId: CallId('c1'),
  763. name: 'raw-tool',
  764. arguments: { path: '/tmp' },
  765. })
  766. expect(result.isError).toBe(false)
  767. expect(result.content).toEqual([{ type: 'text', text: '/tmp' }])
  768. })
  769. })
  770. describe('schema DSL edge cases', () => {
  771. it('emits enum values in JSON Schema property', () => {
  772. const spec = {
  773. color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' },
  774. } satisfies SchemaSpec
  775. const jsonSchema = schemaSpecToJsonSchema(spec)
  776. expect(jsonSchema.properties['color']).toMatchObject({
  777. type: 'string',
  778. enum: ['red', 'green', 'blue'],
  779. description: 'Color choice',
  780. })
  781. })
  782. it('emits default value in JSON Schema property', () => {
  783. const spec = {
  784. limit: { type: 'number', default: 25 },
  785. } satisfies SchemaSpec
  786. const jsonSchema = schemaSpecToJsonSchema(spec)
  787. expect(jsonSchema.properties['limit']).toMatchObject({
  788. type: 'number',
  789. default: 25,
  790. })
  791. })
  792. it('handles array items without nested properties (plain type array)', () => {
  793. const spec = {
  794. tags: { type: 'array', items: { type: 'string' } },
  795. } satisfies SchemaSpec
  796. const jsonSchema = schemaSpecToJsonSchema(spec)
  797. expect(jsonSchema.properties['tags']).toEqual({
  798. type: 'array',
  799. items: { type: 'string' },
  800. })
  801. })
  802. it('handles enum and default together in one property', () => {
  803. const spec = {
  804. level: { type: 'string', enum: ['low', 'high'], default: 'low' },
  805. } satisfies SchemaSpec
  806. const jsonSchema = schemaSpecToJsonSchema(spec)
  807. expect(jsonSchema.properties['level']).toMatchObject({
  808. type: 'string',
  809. enum: ['low', 'high'],
  810. default: 'low',
  811. })
  812. })
  813. it('omits description, enum, default keys when not specified', () => {
  814. const spec = {
  815. bare: { type: 'string' },
  816. } satisfies SchemaSpec
  817. const jsonSchema = schemaSpecToJsonSchema(spec)
  818. const prop = jsonSchema.properties['bare'] as Record<string, unknown>
  819. expect(prop).toEqual({ type: 'string' })
  820. expect('description' in prop).toBe(false)
  821. expect('enum' in prop).toBe(false)
  822. expect('default' in prop).toBe(false)
  823. })
  824. it('handles array with no items (items omitted)', () => {
  825. const spec = {
  826. raw: { type: 'array' },
  827. } satisfies SchemaSpec
  828. const jsonSchema = schemaSpecToJsonSchema(spec)
  829. expect(jsonSchema.properties['raw']).toEqual({
  830. type: 'array',
  831. })
  832. })
  833. it('handles nested object with all-optional properties (no required array)', () => {
  834. const spec = {
  835. config: {
  836. type: 'object',
  837. properties: {
  838. host: { type: 'string' },
  839. port: { type: 'number' },
  840. },
  841. },
  842. } satisfies SchemaSpec
  843. const jsonSchema = schemaSpecToJsonSchema(spec)
  844. expect(jsonSchema.properties['config']).toMatchObject({
  845. type: 'object',
  846. properties: {
  847. host: { type: 'string' },
  848. port: { type: 'number' },
  849. },
  850. })
  851. const config = jsonSchema.properties['config'] as Record<string, unknown>
  852. expect('required' in config).toBe(false)
  853. })
  854. })
  855. describe('schema DSL optional and nested contracts', () => {
  856. it('InferArgs makes non-required keys genuinely optional (omittable)', () => {
  857. type Args = InferArgs<{
  858. path: { type: 'string'; required: true }
  859. limit: { type: 'number' }
  860. }>
  861. expectTypeOf<Args>().toEqualTypeOf<{ path: string; limit?: number }>()
  862. const omitted: Args = { path: '/tmp' }
  863. expect(omitted.limit).toBeUndefined()
  864. })
  865. it('InferArgs recurses into array items, including arrays of objects', () => {
  866. type Args = InferArgs<{
  867. names: { type: 'array'; required: true; items: { type: 'string' } }
  868. servers: {
  869. type: 'array'
  870. items: {
  871. type: 'object'
  872. properties: {
  873. host: { type: 'string'; required: true }
  874. port: { type: 'number' }
  875. }
  876. }
  877. }
  878. }>
  879. expectTypeOf<Args>().toEqualTypeOf<{
  880. names: string[]
  881. servers?: { host: string; port?: number }[]
  882. }>()
  883. })
  884. it('runtime JSON Schema matches the array-of-objects inference', () => {
  885. const spec = {
  886. servers: {
  887. type: 'array',
  888. items: {
  889. type: 'object',
  890. properties: {
  891. host: { type: 'string', required: true },
  892. port: { type: 'number' },
  893. },
  894. },
  895. },
  896. } satisfies SchemaSpec
  897. expect(schemaSpecToJsonSchema(spec)).toEqual({
  898. type: 'object',
  899. properties: {
  900. servers: {
  901. type: 'array',
  902. items: {
  903. type: 'object',
  904. properties: {
  905. host: { type: 'string' },
  906. port: { type: 'number' },
  907. },
  908. required: ['host'],
  909. },
  910. },
  911. },
  912. })
  913. })
  914. it('reports messages from non-Error throws (throw { message })', async () => {
  915. const ctx = await setup()
  916. ctx.tools.register({
  917. ...echoTool,
  918. name: 'object-thrower',
  919. async execute() {
  920. // testing non-Error throws on purpose
  921. throw { message: 'denied by object' }
  922. },
  923. })
  924. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-thrower', arguments: {} })
  925. expect(result.isError).toBe(true)
  926. expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' })
  927. })
  928. it('reports messages from throws of non-objects (throw "string")', async () => {
  929. const ctx = await setup()
  930. ctx.tools.register({
  931. ...echoTool,
  932. name: 'string-thrower',
  933. async execute() {
  934. // testing primitive throws on purpose
  935. throw 'kaboom'
  936. },
  937. })
  938. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'string-thrower', arguments: {} })
  939. expect(result.isError).toBe(true)
  940. expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
  941. })
  942. it('reports messages from throws of objects without message property', async () => {
  943. const ctx = await setup()
  944. ctx.tools.register({
  945. ...echoTool,
  946. name: 'object-no-message',
  947. async execute() {
  948. // testing object throw without .message
  949. throw { code: 500 }
  950. },
  951. })
  952. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-no-message', arguments: {} })
  953. expect(result.isError).toBe(true)
  954. const firstContent = result.content[0]!
  955. expect(firstContent.type).toBe('text')
  956. if (firstContent.type === 'text') {
  957. expect(firstContent.text).toBe('Error: [object Object]')
  958. }
  959. })
  960. })
  961. describe('ToolRegistry.get', () => {
  962. it('get() returns the registered tool definition', async () => {
  963. const ctx = await setup()
  964. ctx.tools.register(echoTool)
  965. const tool = ctx.tools.get('echo')
  966. expect(tool).toBeDefined()
  967. expect(tool!.name).toBe('echo')
  968. })
  969. it('get() returns undefined for unknown tool names', async () => {
  970. const ctx = await setup()
  971. expect(ctx.tools.get('nope')).toBeUndefined()
  972. })
  973. })
  974. describe('validateArgs (the runtime-validation RFC, part 1)', () => {
  975. it('returns [] for valid args and is total over malformed input', () => {
  976. const spec = {
  977. path: { type: 'string', required: true },
  978. limit: { type: 'number' },
  979. } satisfies SchemaSpec
  980. expect(validateArgs(spec, { path: '/tmp' })).toEqual([])
  981. expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([])
  982. // never throws regardless of shape
  983. expect(validateArgs(spec, null)).toHaveLength(1)
  984. expect(validateArgs(spec, 'nope')).toHaveLength(1)
  985. expect(validateArgs(spec, [])).toHaveLength(1)
  986. })
  987. it('flags a missing required key and a required key present as undefined', () => {
  988. const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
  989. expect(validateArgs(spec, {})).toEqual(['missing required property "path"'])
  990. expect(validateArgs(spec, { path: undefined })).toEqual(['missing required property "path"'])
  991. })
  992. it('allows extra keys (no additionalProperties:false) and omitted optionals', () => {
  993. const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
  994. expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([])
  995. })
  996. it('does not apply defaults (validation only)', () => {
  997. const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec
  998. // absent optional is valid, and validation does not synthesize the default
  999. expect(validateArgs(spec, {})).toEqual([])
  1000. })
  1001. it('type-checks primitives', () => {
  1002. const spec = {
  1003. s: { type: 'string' },
  1004. n: { type: 'number' },
  1005. b: { type: 'boolean' },
  1006. } satisfies SchemaSpec
  1007. expect(validateArgs(spec, { s: 1 })).toEqual(['"s" must be a string'])
  1008. expect(validateArgs(spec, { n: 'x' })).toEqual(['"n" must be a number'])
  1009. expect(validateArgs(spec, { b: 'x' })).toEqual(['"b" must be a boolean'])
  1010. })
  1011. it('checks enum membership', () => {
  1012. const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies SchemaSpec
  1013. expect(validateArgs(spec, { color: 'red' })).toEqual([])
  1014. expect(validateArgs(spec, { color: 'blue' })).toEqual(['"color" must be one of ["red","green"]'])
  1015. })
  1016. it('checks enum uniformly with the converter (enum on a non-string prop)', () => {
  1017. // The converter emits `enum` regardless of type; the validator must agree.
  1018. // `enum` is string[], so a number value can never be a member.
  1019. const spec = { n: { type: 'number', enum: ['1', '2'] } } as unknown as SchemaSpec
  1020. expect(validateArgs(spec, { n: 1 })).toEqual(['"n" must be one of ["1","2"]'])
  1021. })
  1022. it('rejects an unknown SchemaType at runtime (assertNever guard)', () => {
  1023. const spec = { x: { type: 'weird' } } as unknown as SchemaSpec
  1024. expect(() => validateArgs(spec, { x: 1 })).toThrow(/unreachable variant.*validateArgs/)
  1025. })
  1026. it('recurses into nested objects (and an object without properties only type-checks)', () => {
  1027. const spec = {
  1028. config: {
  1029. type: 'object',
  1030. required: true,
  1031. properties: { host: { type: 'string', required: true }, port: { type: 'number' } },
  1032. },
  1033. bag: { type: 'object' },
  1034. } satisfies SchemaSpec
  1035. expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([])
  1036. expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([
  1037. 'missing required property "config.host"',
  1038. '"bag" must be an object',
  1039. ])
  1040. })
  1041. it('recurses into array items (and an array without items only type-checks)', () => {
  1042. const spec = {
  1043. tags: { type: 'array', items: { type: 'string' } },
  1044. raw: { type: 'array' },
  1045. } satisfies SchemaSpec
  1046. expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([])
  1047. expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string'])
  1048. // a non-array value for an array-typed prop
  1049. expect(validateArgs(spec, { tags: 'nope' })).toEqual(['"tags" must be an array'])
  1050. })
  1051. it('validates arrays of objects element-wise', () => {
  1052. const spec = {
  1053. servers: {
  1054. type: 'array',
  1055. items: { type: 'object', properties: { host: { type: 'string', required: true } } },
  1056. },
  1057. } satisfies SchemaSpec
  1058. expect(validateArgs(spec, { servers: [{ host: 'a' }, {}] })).toEqual([
  1059. 'missing required property "servers[1].host"',
  1060. ])
  1061. })
  1062. })
  1063. describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
  1064. it('returns an isError result with the violations when the model sends bad args', async () => {
  1065. const ctx = await setup()
  1066. ctx.tools.register(defineTool({
  1067. name: 'reader',
  1068. description: 'reads a path',
  1069. parameters: { path: { type: 'string', required: true } },
  1070. async execute(args) {
  1071. return [{ type: 'text', text: args.path }]
  1072. },
  1073. }))
  1074. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
  1075. expect(result.isError).toBe(true)
  1076. expect(result.content[0]).toMatchObject({
  1077. text: 'Error: invalid arguments: missing required property "path"',
  1078. })
  1079. })
  1080. it('runs execute normally when args are valid', async () => {
  1081. const ctx = await setup()
  1082. ctx.tools.register(defineTool({
  1083. name: 'reader',
  1084. description: 'reads a path',
  1085. parameters: { path: { type: 'string', required: true } },
  1086. async execute(args) {
  1087. return [{ type: 'text', text: `read ${args.path}` }]
  1088. },
  1089. }))
  1090. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
  1091. expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false })
  1092. })
  1093. it('ToolArgsError carries a stable code and the violation list', () => {
  1094. const err = new ToolArgsError(['missing required property "a"', '"b" must be a number'])
  1095. expect(err).toBeInstanceOf(Error)
  1096. expect(err.name).toBe('ToolArgsError')
  1097. expect(err.code).toBe('INVALID_ARGS')
  1098. expect(err.violations).toEqual(['missing required property "a"', '"b" must be a number'])
  1099. expect(err.message).toBe('invalid arguments: missing required property "a"; "b" must be a number')
  1100. })
  1101. it('a schema-invalid call surfaces the structured error on the result', async () => {
  1102. const ctx = await setup()
  1103. ctx.tools.register(defineTool({
  1104. name: 'reader',
  1105. description: 'reads a path',
  1106. parameters: { path: { type: 'string', required: true } },
  1107. async execute(args) {
  1108. return [{ type: 'text', text: args.path }]
  1109. },
  1110. }))
  1111. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
  1112. expect(result.isError).toBe(true)
  1113. expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' })
  1114. })
  1115. it('a tool throwing a HarnessError surfaces its name and code', async () => {
  1116. const { HarnessError } = await import('@deepseek-ai/dsh-llm')
  1117. const ctx = await setup()
  1118. ctx.tools.register({
  1119. ...echoTool,
  1120. name: 'coded',
  1121. async execute() {
  1122. throw new HarnessError('disk full', 'ENOSPC')
  1123. },
  1124. })
  1125. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} })
  1126. expect(result.isError).toBe(true)
  1127. expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' })
  1128. expect(result.content[0]).toMatchObject({ text: 'Error: disk full' })
  1129. })
  1130. it('a non-HarnessError throw has no structured error (only the text)', async () => {
  1131. const ctx = await setup()
  1132. ctx.tools.register({
  1133. ...echoTool,
  1134. name: 'plain',
  1135. async execute() {
  1136. throw new Error('just a message')
  1137. },
  1138. })
  1139. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} })
  1140. expect(result.isError).toBe(true)
  1141. expect(result.error).toBeUndefined()
  1142. expect(result.content[0]).toMatchObject({ text: 'Error: just a message' })
  1143. })
  1144. it('raw-registered tools are NOT validated by defineTool (MCP keeps its own)', async () => {
  1145. const ctx = await setup()
  1146. // A raw ToolDefinition: no defineTool wrapping, so no validateArgs guard.
  1147. ctx.tools.register({
  1148. name: 'raw',
  1149. description: 'raw tool',
  1150. parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
  1151. async execute(args: unknown) {
  1152. return [{ type: 'text', text: typeof args }]
  1153. },
  1154. })
  1155. // Missing the "required" path — but raw tools validate their own input, so
  1156. // this reaches execute rather than being rejected by the harness.
  1157. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
  1158. expect(result.isError).toBe(false)
  1159. })
  1160. it('attaches a positive-finite timeoutMs to the definition', () => {
  1161. const tool = defineTool({
  1162. name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000,
  1163. async execute() { return [{ type: 'text' as const, text: 'ok' }] },
  1164. })
  1165. expect(tool.timeoutMs).toBe(30_000)
  1166. })
  1167. it('omits timeoutMs when not declared', () => {
  1168. const tool = defineTool({
  1169. name: 'x', description: 'd', parameters: {},
  1170. async execute() { return [{ type: 'text' as const, text: 'ok' }] },
  1171. })
  1172. expect(tool.timeoutMs).toBeUndefined()
  1173. })
  1174. it('throws when timeoutMs is zero or negative', () => {
  1175. const make = (ms: number) => defineTool({
  1176. name: 'x', description: 'd', parameters: {}, timeoutMs: ms,
  1177. async execute() { return [{ type: 'text' as const, text: 'ok' }] },
  1178. })
  1179. expect(() => make(0)).toThrow('timeoutMs must be a positive finite number')
  1180. expect(() => make(-5)).toThrow('positive finite number')
  1181. })
  1182. it('throws when timeoutMs is non-finite', () => {
  1183. expect(() => defineTool({
  1184. name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity,
  1185. async execute() { return [{ type: 'text' as const, text: 'ok' }] },
  1186. })).toThrow('positive finite number')
  1187. })
  1188. })
  1189. describe('defineTool presentation (presentCall / presentResult)', () => {
  1190. it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
  1191. const tool = defineTool({
  1192. name: 'demo',
  1193. description: 'demo',
  1194. parameters: { path: { type: 'string', required: true }, n: { type: 'number' } },
  1195. async execute() { return [{ type: 'text', text: 'ok' }] },
  1196. presentCall(args) {
  1197. // args is typed { path: string; n?: number } — zero casts.
  1198. expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>()
  1199. return { card: 'generic', title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
  1200. },
  1201. presentResult(args, result) {
  1202. return { card: 'generic', title: `Opened ${args.path}`, content: result.content }
  1203. },
  1204. })
  1205. expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ card: 'generic', title: 'Open /a', kind: 'read', rawInput: '/a' })
  1206. expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false }))
  1207. .toEqual({ card: 'generic', title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
  1208. })
  1209. it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
  1210. const tool = defineTool({
  1211. name: 'plain',
  1212. description: 'plain',
  1213. parameters: { x: { type: 'string', required: true } },
  1214. async execute() { return [] },
  1215. })
  1216. expect(typeof tool.presentCall).toBe('undefined')
  1217. expect(typeof tool.presentResult).toBe('undefined')
  1218. })
  1219. it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => {
  1220. const tool = defineTool({
  1221. name: 'demo',
  1222. description: 'demo',
  1223. parameters: { path: { type: 'string', required: true } },
  1224. async execute() { return [] },
  1225. presentCall: args => ({ card: 'generic', title: args.path }),
  1226. presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }),
  1227. })
  1228. // Unlike execute (which throws ToolArgsError on a mismatch), the display
  1229. // methods soft-validate and fall back to undefined so a UI never crashes
  1230. // replaying an old/foreign log entry. The ToolDefinition methods take
  1231. // `unknown`, so malformed shapes pass without a cast.
  1232. expect(tool.presentCall?.({})).toBeUndefined()
  1233. expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
  1234. })
  1235. })