tools.spec.ts 65 KB

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