@@ -72,6 +72,20 @@ const mockChatCommands: ChatCommandDefinition[] = [
7272] ;
7373
7474const mockPluginSpecs = [ { name : "tts" , description : "Text to speech" , acceptsArgs : false } ] ;
75+ const runtimeMocks = vi . hoisted ( ( ) => ( {
76+ gatewayRegistry : null as null | {
77+ commands : Array < {
78+ pluginId : string ;
79+ command : {
80+ name : string ;
81+ description : string ;
82+ acceptsArgs ?: boolean ;
83+ nativeNames ?: Record < string , string > ;
84+ channels ?: string [ ] ;
85+ } ;
86+ } > ;
87+ } ,
88+ } ) ) ;
7589
7690vi . mock ( "../../auto-reply/commands-registry.js" , ( ) => ( {
7791 listChatCommandsForConfig : vi . fn ( ( ) => mockChatCommands ) ,
@@ -80,15 +94,66 @@ vi.mock("../../skills/discovery/chat-commands.js", () => ({
8094 listSkillCommandsForAgents : vi . fn ( ( ) => mockSkillCommands ) ,
8195} ) ) ;
8296vi . mock ( "../../plugins/command-specs.js" , ( ) => ( {
83- getPluginCommandSpecs : vi . fn ( ( provider ?: string ) => {
97+ getPluginCommandEntrySpecs : vi . fn ( ( provider ?: string ) => {
8498 if ( provider === "whatsapp" ) {
85- return [ ] ;
99+ return [ { name : "tts" , description : "Text to speech" , acceptsArgs : false } ] ;
86100 }
87101 if ( provider === "discord" ) {
88- return [ { name : "discord_tts" , description : "Text to speech" , acceptsArgs : false } ] ;
102+ return [
103+ {
104+ name : "tts" ,
105+ nativeName : "discord_tts" ,
106+ description : "Text to speech" ,
107+ acceptsArgs : false ,
108+ } ,
109+ ] ;
89110 }
90- return mockPluginSpecs ;
111+ return mockPluginSpecs . map ( ( entry ) => ( {
112+ name : entry . name ,
113+ nativeName : entry . name ,
114+ description : entry . description ,
115+ acceptsArgs : entry . acceptsArgs ,
116+ } ) ) ;
91117 } ) ,
118+ getPluginCommandEntrySpecsFromRegistrations : vi . fn (
119+ (
120+ commands : Array < {
121+ command : {
122+ name : string ;
123+ description : string ;
124+ acceptsArgs ?: boolean ;
125+ nativeNames ?: Record < string , string > ;
126+ channels ?: string [ ] ;
127+ } ;
128+ } > ,
129+ provider ?: string ,
130+ ) => {
131+ return commands
132+ . filter (
133+ ( entry ) =>
134+ ! provider || ! entry . command . channels || entry . command . channels . includes ( provider ) ,
135+ )
136+ . map ( ( entry ) => {
137+ const spec : {
138+ name : string ;
139+ nativeName ?: string ;
140+ description : string ;
141+ acceptsArgs : boolean ;
142+ } = {
143+ name : entry . command . name . trim ( ) ,
144+ description : entry . command . description . trim ( ) ,
145+ acceptsArgs : entry . command . acceptsArgs ?? false ,
146+ } ;
147+ if ( provider !== "whatsapp" ) {
148+ spec . nativeName =
149+ ( provider ? entry . command . nativeNames ?. [ provider ] : undefined ) ??
150+ entry . command . nativeNames ?. default ??
151+ entry . command . name . trim ( ) ;
152+ }
153+ return spec ;
154+ } ) ;
155+ } ,
156+ ) ,
92157} ) ) ;
93158vi . mock ( "../../plugins/commands.js" , ( ) => ( {
94159 listPluginCommands : vi . fn ( ( ) => [
@@ -100,6 +165,9 @@ vi.mock("../../plugins/commands.js", () => ({
100165 } ,
101166 ] ) ,
102167} ) ) ;
168+ vi . mock ( "../../plugins/runtime.js" , ( ) => ( {
169+ getActivePluginGatewayCommandRegistry : vi . fn ( ( ) => runtimeMocks . gatewayRegistry ) ,
170+ } ) ) ;
103171vi . mock ( "../../config/config.js" , ( ) => ( {
104172 getRuntimeConfig : vi . fn ( ( ) => ( { } ) ) ,
105173} ) ) ;
@@ -202,6 +270,7 @@ function collectBuiltinNames(commands: readonly { name: string; source: string }
202270
203271describe ( "commands.list handler" , ( ) => {
204272 beforeEach ( ( ) => {
273+ runtimeMocks . gatewayRegistry = null ;
205274 vi . clearAllMocks ( ) ;
206275 } ) ;
207276
@@ -386,6 +455,119 @@ describe("commands.list handler", () => {
386455 expect ( plugin ?. textAliases ) . toEqual ( [ "/tts" ] ) ;
387456 } ) ;
388457
458+ it ( "reads plugin commands from the gateway registry before the global command table" , ( ) => {
459+ runtimeMocks . gatewayRegistry = {
460+ commands : [
461+ {
462+ pluginId : "phone-control" ,
463+ command : {
464+ name : " phone " ,
465+ description : " Control paired phones " ,
466+ acceptsArgs : true ,
467+ } ,
468+ } ,
469+ ] ,
470+ } ;
471+
472+ const { payload } = callHandler ( ) ;
473+ const { commands } = payload as {
474+ commands : Array < {
475+ name : string ;
476+ description : string ;
477+ source : string ;
478+ textAliases ?: string [ ] ;
479+ acceptsArgs ?: boolean ;
480+ } > ;
481+ } ;
482+ const phone = commands . find ( ( c ) => c . source === "plugin" ) ;
483+
484+ expect ( phone ?. name ) . toBe ( "phone" ) ;
485+ expect ( phone ?. description ) . toBe ( "Control paired phones" ) ;
486+ expect ( phone ?. textAliases ) . toEqual ( [ "/phone" ] ) ;
487+ expect ( phone ?. acceptsArgs ) . toBe ( true ) ;
488+ expect ( commands . find ( ( c ) => c . source === "plugin" && c . name === "tts" ) ) . toBeUndefined ( ) ;
489+ } ) ;
490+
491+ it ( "keeps provider-filtered native plugin names paired with their text aliases" , ( ) => {
492+ runtimeMocks . gatewayRegistry = {
493+ commands : [
494+ {
495+ pluginId : "android-only" ,
496+ command : {
497+ name : "android_only" ,
498+ description : "Android-only command" ,
499+ channels : [ "android" ] ,
500+ } ,
501+ } ,
502+ {
503+ pluginId : "phone-control" ,
504+ command : {
505+ name : "phone" ,
506+ description : "Control paired phones" ,
507+ acceptsArgs : true ,
508+ channels : [ "discord" ] ,
509+ nativeNames : { discord : "discord_phone" } ,
510+ } ,
511+ } ,
512+ ] ,
513+ } ;
514+
515+ const { payload } = callHandler ( { provider : "discord" } ) ;
516+ const { commands } = payload as {
517+ commands : Array < {
518+ name : string ;
519+ source : string ;
520+ textAliases ?: string [ ] ;
521+ nativeName ?: string ;
522+ } > ;
523+ } ;
524+ const plugin = commands . find ( ( c ) => c . source === "plugin" ) ;
525+
526+ expect ( plugin ?. name ) . toBe ( "discord_phone" ) ;
527+ expect ( plugin ?. nativeName ) . toBe ( "discord_phone" ) ;
528+ expect ( plugin ?. textAliases ) . toEqual ( [ "/phone" ] ) ;
529+ expect (
530+ commands . find ( ( c ) => c . source === "plugin" && c . name === "android_only" ) ,
531+ ) . toBeUndefined ( ) ;
532+ } ) ;
533+
534+ it ( "filters provider-incompatible plugin commands from the text surface" , ( ) => {
535+ runtimeMocks . gatewayRegistry = {
536+ commands : [
537+ {
538+ pluginId : "android-only" ,
539+ command : {
540+ name : "android_only" ,
541+ description : "Android-only command" ,
542+ channels : [ "android" ] ,
543+ } ,
544+ } ,
545+ {
546+ pluginId : "phone-control" ,
547+ command : {
548+ name : "phone" ,
549+ description : "Control paired phones" ,
550+ channels : [ "discord" ] ,
551+ } ,
552+ } ,
553+ ] ,
554+ } ;
555+
556+ const { payload } = callHandler ( { provider : "discord" , scope : "text" } ) ;
557+ const { commands } = payload as {
558+ commands : Array < {
559+ name : string ;
560+ source : string ;
561+ textAliases ?: string [ ] ;
562+ } > ;
563+ } ;
564+
565+ expect (
566+ commands . find ( ( c ) => c . source === "plugin" && c . name === "android_only" ) ,
567+ ) . toBeUndefined ( ) ;
568+ expect ( commands . find ( ( c ) => c . source === "plugin" ) ?. textAliases ) . toEqual ( [ "/phone" ] ) ;
569+ } ) ;
570+
389571 it ( "returns provider-specific plugin command names" , ( ) => {
390572 const { payload } = callHandler ( { provider : "discord" } ) ;
391573 const { commands } = payload as { commands : Array < { name : string ; source : string } > } ;
0 commit comments