11import { describe , test , expect } from 'bun:test'
22import { mock } from 'bun:test'
3+ import { z } from 'zod/v4'
34import { logMock } from '../../../../../../tests/mocks/log'
45import { debugMock } from '../../../../../../tests/mocks/debug'
56
@@ -36,7 +37,16 @@ mock.module('src/utils/searchExtraTools.js', () => ({
3637 isSearchExtraToolsToolAvailable : ( ) => true ,
3738 isSearchExtraToolsEnabled : async ( ) => true ,
3839 isToolReferenceBlock : ( ) => false ,
39- extractDiscoveredToolNames : ( ) => new Set ( [ 'TestTool' , 'SecretTool' ] ) ,
40+ // Mark every name as discovered so tests can exercise tools other than
41+ // TestTool/SecretTool without being blocked by the discovery guard.
42+ extractDiscoveredToolNames : ( ) =>
43+ new Set ( [
44+ 'TestTool' ,
45+ 'SecretTool' ,
46+ 'CronCreate' ,
47+ 'WithDefaults' ,
48+ 'McpTool' ,
49+ ] ) ,
4050 isDeferredToolsDeltaEnabled : ( ) => false ,
4151 getDeferredToolsDelta : ( ) => null ,
4252} ) )
@@ -52,6 +62,7 @@ mock.module('src/utils/messages.js', () => ({
5262 content,
5363 uuid : 'test-uuid' ,
5464 } ) ,
65+ INTERRUPT_MESSAGE_FOR_TOOL_USE : '[Request interrupted]' ,
5566} ) )
5667
5768const { ExecuteTool } = await import ( '../ExecuteTool.js' )
@@ -92,6 +103,48 @@ function makeMockTool(name: string, callResult: unknown = 'ok') {
92103 }
93104}
94105
106+ /**
107+ * Builds a mock tool with a real zod inputSchema, mirroring how actual
108+ * deferred tools (e.g. CronCreateTool) expose their schema. Records the
109+ * params that reach call() so tests can assert what was delegated.
110+ */
111+ function makeMockToolWithSchema (
112+ name : string ,
113+ schema : z . ZodType ,
114+ opts : {
115+ validateInput ?: ( input : Record < string , unknown > ) => {
116+ result : boolean
117+ message ?: string
118+ }
119+ } = { } ,
120+ ) {
121+ const calls : Record < string , unknown > [ ] = [ ]
122+ return {
123+ tool : {
124+ name,
125+ inputSchema : schema ,
126+ call : async ( input : Record < string , unknown > ) => {
127+ calls . push ( input )
128+ return { data : { ok : true , received : input } }
129+ } ,
130+ validateInput : opts . validateInput ,
131+ checkPermissions : async ( ) => ( { behavior : 'allow' as const } ) ,
132+ isEnabled : ( ) => true ,
133+ isConcurrencySafe : ( ) => true ,
134+ isReadOnly : ( ) => false ,
135+ isMcp : false ,
136+ userFacingName : ( ) => name ,
137+ renderToolUseMessage : ( ) => `Running ${ name } ` ,
138+ mapToolResultToToolResultBlockParam : ( content : unknown , id : string ) => ( {
139+ tool_use_id : id ,
140+ type : 'tool_result' ,
141+ content,
142+ } ) ,
143+ } ,
144+ calls,
145+ }
146+ }
147+
95148describe ( 'ExecuteTool' , ( ) => {
96149 test ( 'executes a target tool by name' , async ( ) => {
97150 const mockTarget = makeMockTool ( 'TestTool' , { result : 'success' } )
@@ -182,4 +235,117 @@ describe('ExecuteTool', () => {
182235 expect ( ExecuteTool . searchHint ) . toContain ( 'execute' )
183236 expect ( ExecuteTool . searchHint ) . toContain ( 'tool' )
184237 } )
238+
239+ test ( 'schema-validates params against target tool before delegating' , async ( ) => {
240+ // Reproduces the CronCreate bug class: model passes 'schedule' but the
241+ // schema requires 'cron'. Without the pre-validation, params reach
242+ // validateInput with cron=undefined and crash on .trim().
243+ const { tool, calls } = makeMockToolWithSchema (
244+ 'CronCreate' ,
245+ z . strictObject ( {
246+ cron : z . string ( ) ,
247+ prompt : z . string ( ) ,
248+ } ) ,
249+ {
250+ validateInput : input => {
251+ // Mirrors CronCreateTool.validateInput pre-fix behavior — would
252+ // crash on undefined.trim() if schema pre-validation lets bad
253+ // params through. The guard in ExecuteTool must prevent this.
254+ const cron = input . cron as string | undefined
255+ if ( typeof cron !== 'string' ) {
256+ throw new TypeError (
257+ "undefined is not an object (evaluating 'cron.trim')" ,
258+ )
259+ }
260+ return { result : true }
261+ } ,
262+ } ,
263+ )
264+ const ctx = makeContext ( [ tool ] )
265+
266+ const result = await ExecuteTool . call (
267+ {
268+ tool_name : 'CronCreate' ,
269+ params : { schedule : '*/5 * * * *' , prompt : 'hi' } ,
270+ } ,
271+ ctx ,
272+ async ( ) => ( { behavior : 'allow' } ) ,
273+ { type : 'assistant' , content : [ ] , uuid : 'msg1' } as never ,
274+ undefined ,
275+ )
276+
277+ // Schema validation rejects the wrong field name and returns a model-
278+ // friendly error instead of crashing.
279+ expect ( result . data ) . toEqual ( {
280+ result : null ,
281+ tool_name : 'CronCreate' ,
282+ } )
283+ expect ( result . newMessages ) . toBeDefined ( )
284+ const message = result . newMessages ! [ 0 ] . content as string
285+ // Model gets told both what was missing and what was unexpected.
286+ expect ( message ) . toMatch ( / c r o n / i)
287+ expect ( message ) . toMatch ( / s c h e d u l e / i)
288+ // validateInput was never called, so no crash reached it.
289+ expect ( calls . length ) . toBe ( 0 )
290+ } )
291+
292+ test ( 'passes through parsed params to target tool, applying schema defaults' , async ( ) => {
293+ const { tool, calls } = makeMockToolWithSchema (
294+ 'WithDefaults' ,
295+ z . strictObject ( {
296+ cron : z . string ( ) ,
297+ prompt : z . string ( ) ,
298+ recurring : z . boolean ( ) . default ( true ) ,
299+ } ) ,
300+ )
301+ const ctx = makeContext ( [ tool ] )
302+
303+ const result = await ExecuteTool . call (
304+ {
305+ // recurring intentionally omitted — schema default must fill it in.
306+ tool_name : 'WithDefaults' ,
307+ params : { cron : '*/5 * * * *' , prompt : 'hi' } ,
308+ } ,
309+ ctx ,
310+ async ( ) => ( { behavior : 'allow' } ) ,
311+ { type : 'assistant' , content : [ ] , uuid : 'msg1' } as never ,
312+ undefined ,
313+ )
314+
315+ expect ( result . data ) . toEqual ( {
316+ result : {
317+ ok : true ,
318+ received : { cron : '*/5 * * * *' , prompt : 'hi' , recurring : true } ,
319+ } ,
320+ tool_name : 'WithDefaults' ,
321+ } )
322+ expect ( calls . length ) . toBe ( 1 )
323+ // .default() applied — target tool sees recurring: true without
324+ // needing to defend against undefined itself.
325+ expect ( calls [ 0 ] ) . toEqual ( {
326+ cron : '*/5 * * * *' ,
327+ prompt : 'hi' ,
328+ recurring : true ,
329+ } )
330+ } )
331+
332+ test ( 'skips schema validation for tools without safeParse (e.g. MCP)' , async ( ) => {
333+ // MCP tools expose inputJSONSchema, not zod — must not crash on
334+ // duck-typed schema check.
335+ const mockTarget = makeMockTool ( 'McpTool' , { result : 'ok' } )
336+ const ctx = makeContext ( [ mockTarget ] )
337+
338+ const result = await ExecuteTool . call (
339+ { tool_name : 'McpTool' , params : { anything : true } } ,
340+ ctx ,
341+ async ( ) => ( { behavior : 'allow' } ) ,
342+ { type : 'assistant' , content : [ ] , uuid : 'msg1' } as never ,
343+ undefined ,
344+ )
345+
346+ expect ( result . data ) . toEqual ( {
347+ result : { result : 'ok' } ,
348+ tool_name : 'McpTool' ,
349+ } )
350+ } )
185351} )
0 commit comments