@@ -402,3 +402,128 @@ func TestRoundRobinSelectorPick_CursorKeyCap(t *testing.T) {
402402 t .Fatalf ("selector.cursors missing key %q" , "gemini:m3" )
403403 }
404404}
405+
406+ func TestRoundRobinSelectorPick_GeminiCLICredentialGrouping (t * testing.T ) {
407+ t .Parallel ()
408+
409+ selector := & RoundRobinSelector {}
410+
411+ // Simulate two gemini-cli credentials, each with multiple projects:
412+ // Credential A (parent = "cred-a.json") has 3 projects
413+ // Credential B (parent = "cred-b.json") has 2 projects
414+ auths := []* Auth {
415+ {ID : "cred-a.json::proj-a1" , Attributes : map [string ]string {"gemini_virtual_parent" : "cred-a.json" }},
416+ {ID : "cred-a.json::proj-a2" , Attributes : map [string ]string {"gemini_virtual_parent" : "cred-a.json" }},
417+ {ID : "cred-a.json::proj-a3" , Attributes : map [string ]string {"gemini_virtual_parent" : "cred-a.json" }},
418+ {ID : "cred-b.json::proj-b1" , Attributes : map [string ]string {"gemini_virtual_parent" : "cred-b.json" }},
419+ {ID : "cred-b.json::proj-b2" , Attributes : map [string ]string {"gemini_virtual_parent" : "cred-b.json" }},
420+ }
421+
422+ // Two-level round-robin: consecutive picks must alternate between credentials.
423+ // Credential group order is randomized, but within each call the group cursor
424+ // advances by 1, so consecutive picks should cycle through different parents.
425+ picks := make ([]string , 6 )
426+ parents := make ([]string , 6 )
427+ for i := 0 ; i < 6 ; i ++ {
428+ got , err := selector .Pick (context .Background (), "gemini-cli" , "gemini-2.5-pro" , cliproxyexecutor.Options {}, auths )
429+ if err != nil {
430+ t .Fatalf ("Pick() #%d error = %v" , i , err )
431+ }
432+ if got == nil {
433+ t .Fatalf ("Pick() #%d auth = nil" , i )
434+ }
435+ picks [i ] = got .ID
436+ parents [i ] = got .Attributes ["gemini_virtual_parent" ]
437+ }
438+
439+ // Verify property: consecutive picks must alternate between credential groups.
440+ for i := 1 ; i < len (parents ); i ++ {
441+ if parents [i ] == parents [i - 1 ] {
442+ t .Fatalf ("Pick() #%d and #%d both from same parent %q (IDs: %q, %q); expected alternating credentials" ,
443+ i - 1 , i , parents [i ], picks [i - 1 ], picks [i ])
444+ }
445+ }
446+
447+ // Verify property: each credential's projects are picked in sequence (round-robin within group).
448+ credPicks := map [string ][]string {}
449+ for i , id := range picks {
450+ credPicks [parents [i ]] = append (credPicks [parents [i ]], id )
451+ }
452+ for parent , ids := range credPicks {
453+ for i := 1 ; i < len (ids ); i ++ {
454+ if ids [i ] == ids [i - 1 ] {
455+ t .Fatalf ("Credential %q picked same project %q twice in a row" , parent , ids [i ])
456+ }
457+ }
458+ }
459+ }
460+
461+ func TestRoundRobinSelectorPick_SingleParentFallsBackToFlat (t * testing.T ) {
462+ t .Parallel ()
463+
464+ selector := & RoundRobinSelector {}
465+
466+ // All auths from the same parent - should fall back to flat round-robin
467+ // because there's only one credential group (no benefit from two-level).
468+ auths := []* Auth {
469+ {ID : "cred-a.json::proj-a1" , Attributes : map [string ]string {"gemini_virtual_parent" : "cred-a.json" }},
470+ {ID : "cred-a.json::proj-a2" , Attributes : map [string ]string {"gemini_virtual_parent" : "cred-a.json" }},
471+ {ID : "cred-a.json::proj-a3" , Attributes : map [string ]string {"gemini_virtual_parent" : "cred-a.json" }},
472+ }
473+
474+ // With single parent group, parentOrder has length 1, so it uses flat round-robin.
475+ // Sorted by ID: proj-a1, proj-a2, proj-a3
476+ want := []string {
477+ "cred-a.json::proj-a1" ,
478+ "cred-a.json::proj-a2" ,
479+ "cred-a.json::proj-a3" ,
480+ "cred-a.json::proj-a1" ,
481+ }
482+
483+ for i , expectedID := range want {
484+ got , err := selector .Pick (context .Background (), "gemini-cli" , "gemini-2.5-pro" , cliproxyexecutor.Options {}, auths )
485+ if err != nil {
486+ t .Fatalf ("Pick() #%d error = %v" , i , err )
487+ }
488+ if got == nil {
489+ t .Fatalf ("Pick() #%d auth = nil" , i )
490+ }
491+ if got .ID != expectedID {
492+ t .Fatalf ("Pick() #%d auth.ID = %q, want %q" , i , got .ID , expectedID )
493+ }
494+ }
495+ }
496+
497+ func TestRoundRobinSelectorPick_MixedVirtualAndNonVirtualFallsBackToFlat (t * testing.T ) {
498+ t .Parallel ()
499+
500+ selector := & RoundRobinSelector {}
501+
502+ // Mix of virtual and non-virtual auths (e.g., a regular gemini-cli auth without projects
503+ // alongside virtual ones). Should fall back to flat round-robin.
504+ auths := []* Auth {
505+ {ID : "cred-a.json::proj-a1" , Attributes : map [string ]string {"gemini_virtual_parent" : "cred-a.json" }},
506+ {ID : "cred-regular.json" }, // no gemini_virtual_parent
507+ }
508+
509+ // groupByVirtualParent returns nil when any auth lacks the attribute,
510+ // so flat round-robin is used. Sorted by ID: cred-a.json::proj-a1, cred-regular.json
511+ want := []string {
512+ "cred-a.json::proj-a1" ,
513+ "cred-regular.json" ,
514+ "cred-a.json::proj-a1" ,
515+ }
516+
517+ for i , expectedID := range want {
518+ got , err := selector .Pick (context .Background (), "gemini-cli" , "" , cliproxyexecutor.Options {}, auths )
519+ if err != nil {
520+ t .Fatalf ("Pick() #%d error = %v" , i , err )
521+ }
522+ if got == nil {
523+ t .Fatalf ("Pick() #%d auth = nil" , i )
524+ }
525+ if got .ID != expectedID {
526+ t .Fatalf ("Pick() #%d auth.ID = %q, want %q" , i , got .ID , expectedID )
527+ }
528+ }
529+ }
0 commit comments