| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "reasonix/internal/config" |
| 8 | ) |
| 9 | |
| 10 | func TestRemoveProviderAccessesRemovesGroupedOpenCodeGoRoutesAtomically(t *testing.T) { |
| 11 | isolateDesktopUserDirs(t) |
| 12 | setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test") |
| 13 | |
| 14 | cfg := config.Default() |
| 15 | fallback := config.ProviderEntry{ |
| 16 | Name: "mimo-pro", Kind: "openai", BaseURL: "https://mimo.example/v1", |
| 17 | Model: "mimo-v2.5-pro", APIKeyEnv: "REASONIX_TEST_KEY", |
| 18 | } |
| 19 | cfg.DefaultModel = "opencode-go/glm-5.3" |
| 20 | cfg.Agent.PlannerModel = "opencode-go-anthropic/qwen3.7-plus" |
| 21 | cfg.Agent.VisionModel = "opencode-go/kimi-k3" |
| 22 | cfg.Agent.RecoveryModel = "opencode-go-anthropic/qwen3.7-plus" |
| 23 | cfg.Agent.SubagentModel = "opencode-go-responses/grok-4.5" |
| 24 | cfg.Agent.SubagentModels = map[string]string{"review": "opencode-go/glm-5.3"} |
| 25 | cfg.Bot.Model = "opencode-go-anthropic/qwen3.7-plus" |
| 26 | cfg.Bot.QQ.Model = "opencode-go/glm-5.3" |
| 27 | cfg.Bot.Dingtalk.Model = "opencode-go-responses/grok-4.5" |
| 28 | cfg.Bot.Routes = []config.BotRouteConfig{{ConnectionID: "route", Model: "opencode-go-anthropic/qwen3.7-plus"}} |
| 29 | cfg.Bot.Connections = []config.BotConnectionConfig{ |
| 30 | {ID: "opencode", Provider: "feishu", Model: "opencode-go-responses/grok-4.5"}, |
| 31 | {ID: "fallback", Provider: "weixin", Model: "mimo-pro/mimo-v2.5-pro"}, |
| 32 | } |
| 33 | cfg.Desktop.ProviderAccess = []string{ |
| 34 | "opencode-go", "opencode-go-anthropic", "opencode-go-responses", |
| 35 | "opencode-go-deepseek-responses", "mimo-pro", |
| 36 | } |
| 37 | cfg.Providers = []config.ProviderEntry{ |
| 38 | { |
| 39 | Name: "opencode-go", Kind: "openai", BaseURL: "https://opencode.ai/zen/go/v1", |
| 40 | Model: "glm-5.3", APIKeyEnv: "REASONIX_TEST_KEY", PresetID: "opencode-go", |
| 41 | }, |
| 42 | { |
| 43 | Name: "opencode-go-anthropic", Kind: "anthropic", BaseURL: "https://opencode.ai/zen/go", |
| 44 | Model: "qwen3.7-plus", APIKeyEnv: "REASONIX_TEST_KEY", PresetID: "opencode-go-anthropic", |
| 45 | }, |
| 46 | { |
| 47 | Name: "opencode-go-responses", Kind: "responses", BaseURL: "https://opencode.ai/zen/go/v1", |
| 48 | Model: "grok-4.5", APIKeyEnv: "REASONIX_TEST_KEY", PresetID: "opencode-go-responses", |
| 49 | }, |
| 50 | { |
| 51 | Name: "opencode-go-deepseek-responses", Kind: "responses", BaseURL: "https://opencode.ai/zen/go/v1", |
| 52 | Model: "deepseek-v4-flash", APIKeyEnv: "REASONIX_TEST_KEY", PresetID: "opencode-go-deepseek-responses", |
| 53 | }, |
| 54 | fallback, |
| 55 | } |
| 56 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 57 | t.Fatalf("save config: %v", err) |
| 58 | } |
| 59 | |
| 60 | app := NewApp() |
| 61 | tabs := []*WorkspaceTab{ |
| 62 | {ID: "chat", Scope: "global", model: cfg.DefaultModel}, |
| 63 | {ID: "planner", Scope: "global", model: cfg.Agent.PlannerModel}, |
| 64 | {ID: "subagent", Scope: "global", model: cfg.Agent.SubagentModel}, |
| 65 | } |
| 66 | app.tabs = map[string]*WorkspaceTab{} |
| 67 | for _, tab := range tabs { |
| 68 | app.tabs[tab.ID] = tab |
| 69 | } |
| 70 | app.tabOrder = []string{"chat", "planner", "subagent"} |
| 71 | app.activeTabID = "chat" |
| 72 | |
| 73 | if err := app.RemoveProviderAccesses([]string{"opencode-go", "opencode-go-anthropic", "opencode-go-responses", "opencode-go-deepseek-responses"}); err != nil { |
| 74 | t.Fatalf("RemoveProviderAccesses: %v", err) |
| 75 | } |
| 76 | |
| 77 | got := config.LoadForEdit(config.UserConfigPath()) |
| 78 | access := providerAccessSet(got.Desktop.ProviderAccess) |
| 79 | if access["opencode-go"] || access["opencode-go-anthropic"] || access["opencode-go-responses"] || access["opencode-go-deepseek-responses"] || !access["mimo-pro"] { |
| 80 | t.Fatalf("provider_access = %+v, want only mimo-pro", got.Desktop.ProviderAccess) |
| 81 | } |
| 82 | for _, name := range []string{"opencode-go", "opencode-go-anthropic", "opencode-go-responses", "opencode-go-deepseek-responses"} { |
| 83 | if _, ok := got.Provider(name); ok { |
| 84 | t.Fatalf("provider %q still exists after grouped removal", name) |
| 85 | } |
| 86 | } |
| 87 | wantFallback := "mimo-pro" |
| 88 | if got.DefaultModel != wantFallback || got.Agent.PlannerModel != wantFallback || got.Agent.RecoveryModel != "" || got.Agent.SubagentModel != wantFallback || got.Agent.SubagentModels["review"] != wantFallback { |
| 89 | t.Fatalf("provider refs were not retargeted: default=%q planner=%q recovery=%q subagent=%q skills=%+v", got.DefaultModel, got.Agent.PlannerModel, got.Agent.RecoveryModel, got.Agent.SubagentModel, got.Agent.SubagentModels) |
| 90 | } |
| 91 | if got.Agent.VisionModel != "" { |
| 92 | t.Fatalf("vision_model = %q, want cleared removed provider reference", got.Agent.VisionModel) |
| 93 | } |
| 94 | if got.Bot.Model != wantFallback || got.Bot.QQ.Model != wantFallback || got.Bot.Dingtalk.Model != wantFallback || len(got.Bot.Routes) != 1 || got.Bot.Routes[0].Model != wantFallback || len(got.Bot.Connections) != 2 || got.Bot.Connections[0].Model != wantFallback || got.Bot.Connections[1].Model != "mimo-pro/mimo-v2.5-pro" { |
| 95 | t.Fatalf("bot refs were not retargeted selectively: model=%q qq=%q dingtalk=%q routes=%+v connections=%+v", got.Bot.Model, got.Bot.QQ.Model, got.Bot.Dingtalk.Model, got.Bot.Routes, got.Bot.Connections) |
| 96 | } |
| 97 | for index, tab := range tabs { |
| 98 | if tab.model != []string{cfg.DefaultModel, cfg.Agent.PlannerModel, cfg.Agent.SubagentModel}[index] { |
| 99 | t.Fatalf("saving grouped removal changed tab %q model = %q", tab.ID, tab.model) |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestProviderRemovalStateFingerprintCoversAuxiliaryModelReferences(t *testing.T) { |
| 105 | newConfig := func() *config.Config { |
| 106 | cfg := config.Default() |
| 107 | cfg.Providers = []config.ProviderEntry{{Name: "provider", Kind: "openai", BaseURL: "https://provider.example/v1", Model: "model"}} |
| 108 | return cfg |
| 109 | } |
| 110 | base := providerRemovalStateFingerprint(newConfig(), "revision") |
| 111 | tests := []struct { |
| 112 | name string |
| 113 | mutate func(*config.Config) |
| 114 | }{ |
| 115 | {name: "vision", mutate: func(cfg *config.Config) { cfg.Agent.VisionModel = "provider/model" }}, |
| 116 | {name: "guardian", mutate: func(cfg *config.Config) { cfg.Agent.GuardianModel = "provider/model" }}, |
| 117 | {name: "bot default", mutate: func(cfg *config.Config) { cfg.Bot.Model = "provider/model" }}, |
| 118 | {name: "qq", mutate: func(cfg *config.Config) { cfg.Bot.QQ.Model = "provider/model" }}, |
| 119 | {name: "dingtalk", mutate: func(cfg *config.Config) { cfg.Bot.Dingtalk.Model = "provider/model" }}, |
| 120 | {name: "route", mutate: func(cfg *config.Config) { cfg.Bot.Routes = []config.BotRouteConfig{{Model: "provider/model"}} }}, |
| 121 | {name: "connection", mutate: func(cfg *config.Config) { |
| 122 | cfg.Bot.Connections = []config.BotConnectionConfig{{Model: "provider/model"}} |
| 123 | }}, |
| 124 | } |
| 125 | for _, tt := range tests { |
| 126 | t.Run(tt.name, func(t *testing.T) { |
| 127 | cfg := newConfig() |
| 128 | tt.mutate(cfg) |
| 129 | if got := providerRemovalStateFingerprint(cfg, "revision"); got == base { |
| 130 | t.Fatalf("fingerprint did not cover %s model reference", tt.name) |
| 131 | } |
| 132 | }) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func TestRemoveProviderAccessesKeepsCoreRoutesWhenRemovingOpenCodeGoSearchSubset(t *testing.T) { |
| 137 | isolateDesktopUserDirs(t) |
| 138 | setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test") |
| 139 | |
| 140 | coreNames := []string{"opencode-go", "opencode-go-anthropic", "opencode-go-responses"} |
| 141 | searchNames := []string{"opencode-go-deepseek-anthropic", "opencode-go-deepseek-responses"} |
| 142 | cfg := config.Default() |
| 143 | cfg.DefaultModel = "opencode-go/glm-5.3" |
| 144 | cfg.Desktop.ProviderAccess = append(append(append([]string(nil), coreNames...), searchNames...), "fallback") |
| 145 | cfg.Providers = []config.ProviderEntry{ |
| 146 | {Name: "opencode-go", Kind: "openai", BaseURL: "https://opencode.ai/zen/go/v1", Model: "glm-5.3", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 147 | {Name: "opencode-go-anthropic", Kind: "anthropic", BaseURL: "https://opencode.ai/zen/go", Model: "qwen3.7-plus", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 148 | {Name: "opencode-go-responses", Kind: "responses", BaseURL: "https://opencode.ai/zen/go/v1", Model: "grok-4.5", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 149 | {Name: "opencode-go-deepseek-anthropic", Kind: "anthropic", BaseURL: "https://opencode.ai/zen/go", Model: "deepseek-v4-flash", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 150 | {Name: "opencode-go-deepseek-responses", Kind: "responses", BaseURL: "https://opencode.ai/zen/go/v1", Model: "deepseek-v4-flash", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 151 | {Name: "fallback", Kind: "openai", BaseURL: "https://fallback.example/v1", Model: "fallback-model", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 152 | } |
| 153 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 154 | t.Fatalf("save config: %v", err) |
| 155 | } |
| 156 | |
| 157 | if err := NewApp().RemoveProviderAccesses(searchNames); err != nil { |
| 158 | t.Fatalf("RemoveProviderAccesses: %v", err) |
| 159 | } |
| 160 | |
| 161 | got := config.LoadForEdit(config.UserConfigPath()) |
| 162 | for _, name := range coreNames { |
| 163 | if _, ok := got.Provider(name); !ok { |
| 164 | t.Fatalf("core OpenCode Go provider %q was removed with the search subset", name) |
| 165 | } |
| 166 | } |
| 167 | for _, name := range searchNames { |
| 168 | if _, ok := got.Provider(name); ok { |
| 169 | t.Fatalf("search provider %q still exists", name) |
| 170 | } |
| 171 | } |
| 172 | if got.DefaultModel != cfg.DefaultModel { |
| 173 | t.Fatalf("default_model = %q, want preserved %q", got.DefaultModel, cfg.DefaultModel) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | func TestRemoveProviderAccessesStillRejectsUnrelatedCustomBatch(t *testing.T) { |
| 178 | isolateDesktopUserDirs(t) |
| 179 | setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test") |
| 180 | |
| 181 | cfg := config.Default() |
| 182 | cfg.Desktop.ProviderAccess = []string{"custom-a", "custom-b"} |
| 183 | cfg.Providers = []config.ProviderEntry{ |
| 184 | {Name: "custom-a", Kind: "openai", BaseURL: "https://a.example/v1", Model: "a", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 185 | {Name: "custom-b", Kind: "openai", BaseURL: "https://b.example/v1", Model: "b", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 186 | } |
| 187 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 188 | t.Fatalf("save config: %v", err) |
| 189 | } |
| 190 | |
| 191 | err := NewApp().RemoveProviderAccesses([]string{"custom-a", "custom-b"}) |
| 192 | if err == nil || !strings.Contains(err.Error(), "custom provider") { |
| 193 | t.Fatalf("RemoveProviderAccesses error = %v, want custom-provider batch rejection", err) |
| 194 | } |
| 195 | got := config.LoadForEdit(config.UserConfigPath()) |
| 196 | if _, ok := got.Provider("custom-a"); !ok { |
| 197 | t.Fatal("custom-a was deleted after rejected batch") |
| 198 | } |
| 199 | if _, ok := got.Provider("custom-b"); !ok { |
| 200 | t.Fatal("custom-b was deleted after rejected batch") |
| 201 | } |
| 202 | } |
| 203 |