@@ -474,6 +474,45 @@ describe('ToolSearchProcessor', () => {
474474 expect ( loadResult . message ) . toContain ( 'Did you mean' ) ;
475475 } ) ;
476476
477+ it ( 'should preserve key-only suggestions when filter is omitted' , async ( ) => {
478+ const processor = new ToolSearchProcessor ( {
479+ tools : {
480+ weather : createMockTool ( 'weather_tool_id' , 'Get weather' ) ,
481+ } ,
482+ } ) ;
483+
484+ const args = createMockArgs ( 'thread-suggestion-key' ) ;
485+ const result = await processor . processInputStep ( args ) ;
486+ const loadTool = result . tools ?. load_tool ;
487+
488+ const loadResult = await loadTool ! . execute ?.( { toolName : 'weath' } , undefined ) ;
489+
490+ expect ( loadResult . success ) . toBe ( false ) ;
491+ expect ( loadResult . message ) . toContain ( 'Did you mean: weather' ) ;
492+ expect ( loadResult . message ) . not . toContain ( 'weather_tool_id' ) ;
493+ } ) ;
494+
495+ it ( 'should preserve key-first load resolution when filter is omitted' , async ( ) => {
496+ const keyedTool = createMockTool ( 'public_weather' , 'Public weather' ) ;
497+ const idCollisionTool = createMockTool ( 'weather' , 'Private weather' ) ;
498+ const processor = new ToolSearchProcessor ( {
499+ tools : {
500+ weather : keyedTool ,
501+ private_weather : idCollisionTool ,
502+ } ,
503+ } ) ;
504+
505+ const args1 = createMockArgs ( 'thread-no-filter-key-id-collision' ) ;
506+ const result1 = await processor . processInputStep ( args1 ) ;
507+ const loadResult = await result1 . tools ?. load_tool ! . execute ?.( { toolName : 'weather' } , undefined ) ;
508+
509+ expect ( loadResult . success ) . toBe ( true ) ;
510+
511+ const args2 = createMockArgs ( 'thread-no-filter-key-id-collision' ) ;
512+ const result2 = await processor . processInputStep ( args2 ) ;
513+ expect ( result2 . tools ?. weather ) . toBe ( keyedTool ) ;
514+ } ) ;
515+
477516 it ( 'should indicate when tool is already loaded' , async ( ) => {
478517 const processor = new ToolSearchProcessor ( {
479518 tools : {
@@ -695,6 +734,271 @@ describe('ToolSearchProcessor', () => {
695734 } ) ;
696735 } ) ;
697736
737+ describe ( 'request-aware filtering' , ( ) => {
738+ it ( 'should filter search results with filter' , async ( ) => {
739+ const processor = new ToolSearchProcessor ( {
740+ tools : {
741+ weather : createMockTool ( 'weather' , 'Get weather forecast' ) ,
742+ weather_alerts : createMockTool ( 'weather_alerts' , 'Get weather alerts' ) ,
743+ } ,
744+ filter : ( { toolName, phase } ) => phase !== 'search' || toolName !== 'weather_alerts' ,
745+ } ) ;
746+
747+ const args = createMockArgs ( 'thread-filter-search' ) ;
748+ const result = await processor . processInputStep ( args ) ;
749+ const searchResult = await result . tools ?. search_tools ! . execute ?.( { query : 'weather' } , undefined ) ;
750+
751+ expect ( searchResult . results . map ( ( tool : any ) => tool . name ) ) . toEqual ( [ 'weather' ] ) ;
752+ } ) ;
753+
754+ it ( 'should filter search results against the indexed tool id when keys collide with tool ids' , async ( ) => {
755+ const privateTool = createMockTool ( 'weather' , 'Private weather forecast' ) ;
756+ const publicTool = createMockTool ( 'public_weather' , 'Public weather forecast' ) ;
757+ const processor = new ToolSearchProcessor ( {
758+ tools : {
759+ private_weather : privateTool ,
760+ weather : publicTool ,
761+ } ,
762+ filter : ( { toolName } ) => toolName !== 'weather' ,
763+ } ) ;
764+
765+ const args = createMockArgs ( 'thread-filter-key-id-collision' ) ;
766+ const result = await processor . processInputStep ( args ) ;
767+ const searchResult = await result . tools ?. search_tools ! . execute ?.( { query : 'weather' } , undefined ) ;
768+
769+ expect ( searchResult . results . map ( ( tool : any ) => tool . name ) ) . toEqual ( [ 'public_weather' ] ) ;
770+ } ) ;
771+
772+ it ( 'should support async filter hooks' , async ( ) => {
773+ const processor = new ToolSearchProcessor ( {
774+ tools : {
775+ weather : createMockTool ( 'weather' , 'Get weather forecast' ) ,
776+ calendar : createMockTool ( 'calendar' , 'Manage calendar' ) ,
777+ } ,
778+ filter : async ( { toolName, phase } ) => {
779+ await Promise . resolve ( ) ;
780+ return phase !== 'search' || toolName !== 'calendar' ;
781+ } ,
782+ } ) ;
783+
784+ const args = createMockArgs ( 'thread-async-filter' ) ;
785+ const result = await processor . processInputStep ( args ) ;
786+ const searchResult = await result . tools ?. search_tools ! . execute ?.( { query : 'calendar' } , undefined ) ;
787+
788+ expect ( searchResult . results ) . toEqual ( [ ] ) ;
789+ } ) ;
790+
791+ it ( 'should block loading disallowed tools' , async ( ) => {
792+ const processor = new ToolSearchProcessor ( {
793+ tools : {
794+ weather : createMockTool ( 'weather' , 'Get weather' ) ,
795+ } ,
796+ filter : ( { phase } ) => phase !== 'load' ,
797+ } ) ;
798+
799+ const args1 = createMockArgs ( 'thread-load-filter' ) ;
800+ const result1 = await processor . processInputStep ( args1 ) ;
801+ const loadResult = await result1 . tools ?. load_tool ! . execute ?.( { toolName : 'weather' } , undefined ) ;
802+
803+ expect ( loadResult . success ) . toBe ( false ) ;
804+ expect ( loadResult . toolName ) . toBe ( 'weather' ) ;
805+
806+ const args2 = createMockArgs ( 'thread-load-filter' ) ;
807+ const result2 = await processor . processInputStep ( args2 ) ;
808+ expect ( result2 . tools ?. weather ) . toBeUndefined ( ) ;
809+ } ) ;
810+
811+ it ( 'should filter load requests against the resolved tool id when keys collide with tool ids' , async ( ) => {
812+ const privateTool = createMockTool ( 'weather' , 'Private weather forecast' ) ;
813+ const publicTool = createMockTool ( 'public_weather' , 'Public weather forecast' ) ;
814+ const processor = new ToolSearchProcessor ( {
815+ tools : {
816+ private_weather : privateTool ,
817+ weather : publicTool ,
818+ } ,
819+ filter : ( { toolName } ) => toolName !== 'weather' ,
820+ } ) ;
821+
822+ const args1 = createMockArgs ( 'thread-load-key-id-collision' ) ;
823+ const result1 = await processor . processInputStep ( args1 ) ;
824+ const loadResult = await result1 . tools ?. load_tool ! . execute ?.( { toolName : 'weather' } , undefined ) ;
825+
826+ expect ( loadResult . success ) . toBe ( false ) ;
827+
828+ const args2 = createMockArgs ( 'thread-load-key-id-collision' ) ;
829+ const result2 = await processor . processInputStep ( args2 ) ;
830+ expect ( result2 . tools ?. weather ) . toBeUndefined ( ) ;
831+ } ) ;
832+
833+ it ( 'should not leak disallowed tools in load suggestions' , async ( ) => {
834+ const processor = new ToolSearchProcessor ( {
835+ tools : {
836+ premium_weather : createMockTool ( 'premium_weather' , 'Premium weather' ) ,
837+ public_weather : createMockTool ( 'public_weather' , 'Public weather' ) ,
838+ } ,
839+ filter : ( { toolName } ) => toolName !== 'premium_weather' ,
840+ } ) ;
841+
842+ const args = createMockArgs ( 'thread-filter-suggestions' ) ;
843+ const result = await processor . processInputStep ( args ) ;
844+ const loadResult = await result . tools ?. load_tool ! . execute ?.( { toolName : 'premium' } , undefined ) ;
845+
846+ expect ( loadResult . success ) . toBe ( false ) ;
847+ expect ( loadResult . message ) . not . toContain ( 'premium_weather' ) ;
848+ } ) ;
849+
850+ it ( 'should not suggest filtered key aliases that resolve to disallowed tool ids' , async ( ) => {
851+ const privateTool = createMockTool ( 'weather' , 'Private weather forecast' ) ;
852+ const publicTool = createMockTool ( 'public_weather' , 'Public weather forecast' ) ;
853+ const processor = new ToolSearchProcessor ( {
854+ tools : {
855+ private_weather : privateTool ,
856+ weather : publicTool ,
857+ } ,
858+ filter : ( { toolName } ) => toolName !== 'weather' ,
859+ } ) ;
860+
861+ const args = createMockArgs ( 'thread-filter-suggestions-key-id-collision' ) ;
862+ const result = await processor . processInputStep ( args ) ;
863+ const loadResult = await result . tools ?. load_tool ! . execute ?.( { toolName : 'weath' } , undefined ) ;
864+
865+ expect ( loadResult . success ) . toBe ( false ) ;
866+ expect ( loadResult . message ) . not . toContain ( 'Did you mean: weather' ) ;
867+ } ) ;
868+
869+ it ( 'should fill search results from lower-ranked allowed matches' , async ( ) => {
870+ const processor = new ToolSearchProcessor ( {
871+ tools : {
872+ premium_a : createMockTool ( 'premium_a' , 'Shared capability' ) ,
873+ premium_b : createMockTool ( 'premium_b' , 'Shared capability' ) ,
874+ premium_c : createMockTool ( 'premium_c' , 'Shared capability' ) ,
875+ premium_d : createMockTool ( 'premium_d' , 'Shared capability' ) ,
876+ public_a : createMockTool ( 'public_a' , 'Shared capability' ) ,
877+ public_b : createMockTool ( 'public_b' , 'Shared capability' ) ,
878+ } ,
879+ search : { topK : 2 } ,
880+ filter : ( { toolName, phase } ) => phase !== 'search' || toolName . startsWith ( 'public_' ) ,
881+ } ) ;
882+
883+ const args = createMockArgs ( 'thread-filter-fill' ) ;
884+ const result = await processor . processInputStep ( args ) ;
885+ const searchResult = await result . tools ?. search_tools ! . execute ?.( { query : 'shared' } , undefined ) ;
886+
887+ expect ( searchResult . results . map ( ( tool : any ) => tool . name ) ) . toEqual ( [ 'public_a' , 'public_b' ] ) ;
888+ } ) ;
889+
890+ it ( 'should pass resolved tool id, tool, request context, and phase to filter' , async ( ) => {
891+ const calls : Array < { toolName : string ; tool : Tool < any , any > ; requestContext ?: RequestContext ; phase : string } > =
892+ [ ] ;
893+ const weatherTool = createMockTool ( 'weather_tool_id' , 'Get weather' ) ;
894+ const processor = new ToolSearchProcessor ( {
895+ tools : {
896+ weather : weatherTool ,
897+ } ,
898+ filter : args => {
899+ calls . push ( args ) ;
900+ return true ;
901+ } ,
902+ } ) ;
903+
904+ const args1 = createMockArgs ( 'thread-filter-args' ) ;
905+ args1 . requestContext ?. set ( 'plan' , 'pro' ) ;
906+ const result1 = await processor . processInputStep ( args1 ) ;
907+ await result1 . tools ?. search_tools ! . execute ?.( { query : 'weather' } , undefined ) ;
908+ await result1 . tools ?. load_tool ! . execute ?.( { toolName : 'weather' } , undefined ) ;
909+
910+ const args2 = createMockArgs ( 'thread-filter-args' ) ;
911+ args2 . requestContext ?. set ( 'plan' , 'pro' ) ;
912+ await processor . processInputStep ( args2 ) ;
913+
914+ expect ( calls . map ( call => call . phase ) ) . toEqual ( [ 'search' , 'load' , 'active' ] ) ;
915+ expect ( calls . every ( call => call . toolName === 'weather_tool_id' ) ) . toBe ( true ) ;
916+ expect ( calls . every ( call => call . tool === weatherTool ) ) . toBe ( true ) ;
917+ expect ( calls . every ( call => call . requestContext ?. get ( 'plan' ) === 'pro' ) ) . toBe ( true ) ;
918+ } ) ;
919+
920+ it ( 'should fail closed when filter throws' , async ( ) => {
921+ const processor = new ToolSearchProcessor ( {
922+ tools : {
923+ weather : createMockTool ( 'weather' , 'Get weather' ) ,
924+ } ,
925+ filter : ( ) => {
926+ throw new Error ( 'policy unavailable' ) ;
927+ } ,
928+ } ) ;
929+
930+ const args = createMockArgs ( 'thread-filter-throws' ) ;
931+ const result = await processor . processInputStep ( args ) ;
932+ const searchResult = await result . tools ?. search_tools ! . execute ?.( { query : 'weather' } , undefined ) ;
933+ const loadResult = await result . tools ?. load_tool ! . execute ?.( { toolName : 'weather' } , undefined ) ;
934+
935+ expect ( searchResult . results ) . toEqual ( [ ] ) ;
936+ expect ( loadResult . success ) . toBe ( false ) ;
937+ } ) ;
938+
939+ it ( 'should filter active loaded tools per request without clearing thread state' , async ( ) => {
940+ const processor = new ToolSearchProcessor ( {
941+ tools : {
942+ weather : createMockTool ( 'weather' , 'Get weather' ) ,
943+ } ,
944+ filter : ( { phase, requestContext } ) => {
945+ if ( phase !== 'active' ) return true ;
946+ return requestContext ?. get ( 'allowWeather' ) === true ;
947+ } ,
948+ } ) ;
949+
950+ const args1 = createMockArgs ( 'thread-active-filter' ) ;
951+ args1 . requestContext ?. set ( 'allowWeather' , true ) ;
952+ const result1 = await processor . processInputStep ( args1 ) ;
953+ await result1 . tools ?. load_tool ! . execute ?.( { toolName : 'weather' } , undefined ) ;
954+
955+ const disallowedArgs = createMockArgs ( 'thread-active-filter' ) ;
956+ disallowedArgs . requestContext ?. set ( 'allowWeather' , false ) ;
957+ const disallowedResult = await processor . processInputStep ( disallowedArgs ) ;
958+ expect ( disallowedResult . tools ?. weather ) . toBeUndefined ( ) ;
959+
960+ const allowedArgs = createMockArgs ( 'thread-active-filter' ) ;
961+ allowedArgs . requestContext ?. set ( 'allowWeather' , true ) ;
962+ const allowedResult = await processor . processInputStep ( allowedArgs ) ;
963+ expect ( allowedResult . tools ?. weather ) . toBeDefined ( ) ;
964+ } ) ;
965+
966+ it ( 'should preserve existing tools passed to processInputStep when filter is provided' , async ( ) => {
967+ const existingTool = createMockTool ( 'weather' , 'Existing weather tool' ) ;
968+ const processor = new ToolSearchProcessor ( {
969+ tools : {
970+ weather : createMockTool ( 'weather' , 'Dynamic weather tool' ) ,
971+ } ,
972+ filter : ( ) => false ,
973+ } ) ;
974+
975+ const args = createMockArgs ( 'thread-existing-filter' , { weather : existingTool } ) ;
976+ const result = await processor . processInputStep ( args ) ;
977+
978+ expect ( result . tools ?. weather ) . toBe ( existingTool ) ;
979+ } ) ;
980+
981+ it ( 'should keep existing behavior when filter is omitted' , async ( ) => {
982+ const processor = new ToolSearchProcessor ( {
983+ tools : {
984+ weather : createMockTool ( 'weather' , 'Get weather' ) ,
985+ } ,
986+ } ) ;
987+
988+ const args1 = createMockArgs ( 'thread-no-filter' ) ;
989+ const result1 = await processor . processInputStep ( args1 ) ;
990+ const searchResult = await result1 . tools ?. search_tools ! . execute ?.( { query : 'weather' } , undefined ) ;
991+ const loadResult = await result1 . tools ?. load_tool ! . execute ?.( { toolName : 'weather' } , undefined ) ;
992+
993+ expect ( searchResult . results [ 0 ] . name ) . toBe ( 'weather' ) ;
994+ expect ( loadResult . success ) . toBe ( true ) ;
995+
996+ const args2 = createMockArgs ( 'thread-no-filter' ) ;
997+ const result2 = await processor . processInputStep ( args2 ) ;
998+ expect ( result2 . tools ?. weather ) . toBeDefined ( ) ;
999+ } ) ;
1000+ } ) ;
1001+
6981002 describe ( 'processInputStep integration' , ( ) => {
6991003 it ( 'should return meta-tools (search_tools and load_tool)' , async ( ) => {
7001004 const processor = new ToolSearchProcessor ( {
0 commit comments