tools.spec.ts 55 KB

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