| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/config" |
| 12 | "reasonix/internal/control" |
| 13 | "reasonix/internal/event" |
| 14 | "reasonix/internal/provider" |
| 15 | ) |
| 16 | |
| 17 | func TestRemoveProviderAccessesRemovesGroupedOfficialAliasesAtomically(t *testing.T) { |
| 18 | isolateDesktopUserDirs(t) |
| 19 | setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") |
| 20 | setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test") |
| 21 | |
| 22 | cfg := config.Default() |
| 23 | cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash" |
| 24 | cfg.Agent.PlannerModel = "deepseek-pro/deepseek-v4-pro" |
| 25 | cfg.Agent.SubagentModel = "deepseek-flash/deepseek-v4-flash" |
| 26 | cfg.Agent.SubagentModels = map[string]string{"review": "deepseek-pro/deepseek-v4-pro"} |
| 27 | cfg.Desktop.ProviderAccess = []string{"deepseek-flash", "deepseek-pro", "mimo-pro"} |
| 28 | cfg.Providers = []config.ProviderEntry{ |
| 29 | { |
| 30 | Name: "deepseek-flash", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", |
| 31 | Models: []string{"deepseek-v4-flash"}, Default: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", |
| 32 | Headers: map[string]string{"X-Route": "flash"}, |
| 33 | }, |
| 34 | { |
| 35 | Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com", |
| 36 | Models: []string{"deepseek-v4-pro"}, Default: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY", |
| 37 | Headers: map[string]string{"X-Route": "pro"}, |
| 38 | }, |
| 39 | {Name: "mimo-pro", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", APIKeyEnv: "MIMO_API_KEY"}, |
| 40 | } |
| 41 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 42 | t.Fatalf("save config: %v", err) |
| 43 | } |
| 44 | |
| 45 | app := NewApp() |
| 46 | flashTab := &WorkspaceTab{ID: "flash", Scope: "global", model: "deepseek-flash/deepseek-v4-flash"} |
| 47 | proTab := &WorkspaceTab{ID: "pro", Scope: "global", model: "deepseek-pro/deepseek-v4-pro"} |
| 48 | app.tabs = map[string]*WorkspaceTab{flashTab.ID: flashTab, proTab.ID: proTab} |
| 49 | app.tabOrder = []string{flashTab.ID, proTab.ID} |
| 50 | app.activeTabID = flashTab.ID |
| 51 | |
| 52 | if err := app.RemoveProviderAccesses([]string{"deepseek-flash", "deepseek-pro", "deepseek-flash"}); err != nil { |
| 53 | t.Fatalf("RemoveProviderAccesses: %v", err) |
| 54 | } |
| 55 | |
| 56 | got := config.LoadForEdit(config.UserConfigPath()) |
| 57 | access := providerAccessSet(got.Desktop.ProviderAccess) |
| 58 | if access["deepseek"] || access["deepseek-flash"] || access["deepseek-pro"] || !access["mimo-pro"] { |
| 59 | t.Fatalf("provider_access = %+v, want only mimo-pro", got.Desktop.ProviderAccess) |
| 60 | } |
| 61 | fallback := "mimo-pro/mimo-v2.5-pro" |
| 62 | if got.DefaultModel != fallback || got.Agent.PlannerModel != fallback || got.Agent.SubagentModel != fallback || got.Agent.SubagentModels["review"] != fallback { |
| 63 | t.Fatalf("grouped provider refs were not retargeted: default=%q planner=%q subagent=%q skills=%+v", got.DefaultModel, got.Agent.PlannerModel, got.Agent.SubagentModel, got.Agent.SubagentModels) |
| 64 | } |
| 65 | if flashTab.model != "deepseek-flash/deepseek-v4-flash" || proTab.model != "deepseek-pro/deepseek-v4-pro" { |
| 66 | t.Fatalf("saving grouped removal changed current tab models: %q, %q", flashTab.model, proTab.model) |
| 67 | } |
| 68 | flash, flashOK := got.Provider("deepseek-flash") |
| 69 | pro, proOK := got.Provider("deepseek-pro") |
| 70 | if !flashOK || !proOK || flash.Headers["X-Route"] != "flash" || pro.Headers["X-Route"] != "pro" { |
| 71 | t.Fatalf("built-in profiles or custom fields changed: flash=%+v/%v pro=%+v/%v", flash, flashOK, pro, proOK) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func TestDeleteProviderSavesWithoutBuildingInvalidFallback(t *testing.T) { |
| 76 | isolateDesktopUserDirs(t) |
| 77 | setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test") |
| 78 | |
| 79 | cfg := config.Default() |
| 80 | cfg.DefaultModel = "prov-a/model-a" |
| 81 | cfg.Desktop.ProviderAccess = []string{"prov-a", "broken"} |
| 82 | cfg.Providers = []config.ProviderEntry{ |
| 83 | {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.invalid/v1", Model: "model-a", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 84 | {Name: "broken", Kind: "missing-provider-kind", BaseURL: "https://broken.example.invalid", Model: "model-b", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 85 | } |
| 86 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 87 | t.Fatalf("save config: %v", err) |
| 88 | } |
| 89 | |
| 90 | base := control.New(control.Options{Label: "prov-a/model-a"}) |
| 91 | ctrl := newBlockingSnapshotCtrl(base) |
| 92 | close(ctrl.releaseSnapshot) |
| 93 | app := NewApp() |
| 94 | app.ctx = context.Background() |
| 95 | tab := &WorkspaceTab{ID: "active", Scope: "global", Ready: true, Ctrl: ctrl, model: cfg.DefaultModel, Label: cfg.DefaultModel} |
| 96 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 97 | app.tabOrder = []string{tab.ID} |
| 98 | app.activeTabID = tab.ID |
| 99 | t.Cleanup(func() { |
| 100 | if tab.Ctrl != nil { |
| 101 | tab.Ctrl.Close() |
| 102 | } |
| 103 | }) |
| 104 | |
| 105 | err := app.DeleteProvider("prov-a") |
| 106 | if err != nil { |
| 107 | t.Fatalf("DeleteProvider tried to apply the fallback during save: %v", err) |
| 108 | } |
| 109 | if tab.Ctrl != ctrl || ctrl.closeCount.Load() != 0 { |
| 110 | t.Fatalf("failed replacement closed or replaced the old controller: ctrl=%T closes=%d", tab.Ctrl, ctrl.closeCount.Load()) |
| 111 | } |
| 112 | if tab.model != cfg.DefaultModel || tab.Label != cfg.DefaultModel { |
| 113 | t.Fatalf("failed replacement changed live tab identity: model=%q label=%q", tab.model, tab.Label) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func TestRemoveOfficialProviderAccessPreservesLiveTabUntilNextRun(t *testing.T) { |
| 118 | isolateDesktopUserDirs(t) |
| 119 | setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") |
| 120 | setDesktopTestCredential(t, "GOOD_KEY", "sk-test") |
| 121 | |
| 122 | cfg := config.Default() |
| 123 | cfg.DefaultModel = "deepseek/deepseek-v4-flash" |
| 124 | cfg.Desktop.ProviderAccess = []string{"deepseek", "good"} |
| 125 | cfg.Providers = []config.ProviderEntry{ |
| 126 | { |
| 127 | Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", |
| 128 | Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", |
| 129 | }, |
| 130 | {Name: "good", Kind: "openai", BaseURL: "https://good.example.invalid/v1", Model: "good-model", APIKeyEnv: "GOOD_KEY"}, |
| 131 | } |
| 132 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 133 | t.Fatalf("save config: %v", err) |
| 134 | } |
| 135 | |
| 136 | old := newBlockingSnapshotCtrl(control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard})) |
| 137 | close(old.releaseSnapshot) |
| 138 | app := NewApp() |
| 139 | app.ctx = context.Background() |
| 140 | app.readyHook = func() {} |
| 141 | tab := &WorkspaceTab{ |
| 142 | ID: "active", Scope: "global", Ready: true, Ctrl: old, |
| 143 | model: cfg.DefaultModel, Label: cfg.DefaultModel, |
| 144 | sink: &tabEventSink{tabID: "active", app: app}, disabledMCP: map[string]ServerView{}, |
| 145 | } |
| 146 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 147 | app.tabOrder = []string{tab.ID} |
| 148 | app.activeTabID = tab.ID |
| 149 | t.Cleanup(func() { |
| 150 | if tab.Ctrl != nil { |
| 151 | tab.Ctrl.Close() |
| 152 | } |
| 153 | tab.releaseSessionLease() |
| 154 | }) |
| 155 | |
| 156 | if err := app.RemoveProviderAccess("deepseek"); err != nil { |
| 157 | t.Fatalf("RemoveProviderAccess: %v", err) |
| 158 | } |
| 159 | if tab.model != cfg.DefaultModel || tab.Ctrl != old || old.closeCount.Load() != 0 { |
| 160 | t.Fatalf("saving removal changed the live tab: model=%q ctrl=%T old closes=%d", tab.model, tab.Ctrl, old.closeCount.Load()) |
| 161 | } |
| 162 | got := config.LoadForEdit(config.UserConfigPath()) |
| 163 | if providerAccessSet(got.Desktop.ProviderAccess)["deepseek"] { |
| 164 | t.Fatalf("provider_access still contains DeepSeek: %v", got.Desktop.ProviderAccess) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | func TestDeleteProviderPreservesEveryVisibleRuntimeUsingAuxiliaryProvider(t *testing.T) { |
| 169 | isolateDesktopUserDirs(t) |
| 170 | setDesktopTestCredential(t, "REMOVED_KEY", "sk-test") |
| 171 | setDesktopTestCredential(t, "GOOD_KEY", "sk-test") |
| 172 | |
| 173 | cfg := config.Default() |
| 174 | cfg.DefaultModel = "good/good-model" |
| 175 | cfg.Agent.SubagentModel = "removed/vision-model" |
| 176 | cfg.Desktop.ProviderAccess = []string{"removed", "good"} |
| 177 | cfg.Providers = []config.ProviderEntry{ |
| 178 | {Name: "removed", Kind: "openai", BaseURL: "https://removed.example.invalid/v1", Model: "vision-model", APIKeyEnv: "REMOVED_KEY", Vision: true}, |
| 179 | {Name: "good", Kind: "openai", BaseURL: "https://good.example.invalid/v1", Model: "good-model", APIKeyEnv: "GOOD_KEY"}, |
| 180 | } |
| 181 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 182 | t.Fatalf("save config: %v", err) |
| 183 | } |
| 184 | |
| 185 | app := NewApp() |
| 186 | app.ctx = context.Background() |
| 187 | app.readyHook = func() {} |
| 188 | newTab := func(id string) (*WorkspaceTab, *blockingSnapshotCtrl) { |
| 189 | old := newBlockingSnapshotCtrl(control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard})) |
| 190 | close(old.releaseSnapshot) |
| 191 | tab := &WorkspaceTab{ |
| 192 | ID: id, Scope: "global", Ready: true, Ctrl: old, |
| 193 | model: cfg.DefaultModel, Label: cfg.DefaultModel, |
| 194 | sink: &tabEventSink{tabID: id, app: app}, disabledMCP: map[string]ServerView{}, |
| 195 | } |
| 196 | return tab, old |
| 197 | } |
| 198 | first, oldFirst := newTab("first") |
| 199 | second, oldSecond := newTab("second") |
| 200 | app.tabs = map[string]*WorkspaceTab{first.ID: first, second.ID: second} |
| 201 | app.tabOrder = []string{first.ID, second.ID} |
| 202 | app.activeTabID = first.ID |
| 203 | t.Cleanup(func() { |
| 204 | for _, tab := range []*WorkspaceTab{first, second} { |
| 205 | if tab.Ctrl != nil { |
| 206 | tab.Ctrl.Close() |
| 207 | } |
| 208 | tab.releaseSessionLease() |
| 209 | } |
| 210 | }) |
| 211 | |
| 212 | if err := app.DeleteProvider("removed"); err != nil { |
| 213 | t.Fatalf("DeleteProvider: %v", err) |
| 214 | } |
| 215 | if first.Ctrl != oldFirst || second.Ctrl != oldSecond || oldFirst.closeCount.Load() != 0 || oldSecond.closeCount.Load() != 0 { |
| 216 | t.Fatalf("saving auxiliary-provider removal replaced a runtime: first=%T/%d second=%T/%d", first.Ctrl, oldFirst.closeCount.Load(), second.Ctrl, oldSecond.closeCount.Load()) |
| 217 | } |
| 218 | if first.model != cfg.DefaultModel || second.model != cfg.DefaultModel { |
| 219 | t.Fatalf("unaffected chat models changed: first=%q second=%q", first.model, second.model) |
| 220 | } |
| 221 | got := config.LoadForEdit(config.UserConfigPath()) |
| 222 | if _, ok := got.Provider("removed"); ok { |
| 223 | t.Fatal("removed auxiliary provider still exists") |
| 224 | } |
| 225 | if got.Agent.SubagentModel != "good" { |
| 226 | t.Fatalf("subagent_model = %q, want persisted visible fallback", got.Agent.SubagentModel) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | func TestDeleteProviderPreservesNonActiveWorkspaceAndProjectAuxiliaryReference(t *testing.T) { |
| 231 | isolateDesktopUserDirs(t) |
| 232 | setDesktopTestCredential(t, "REMOVED_KEY", "sk-test") |
| 233 | setDesktopTestCredential(t, "GOOD_KEY", "sk-test") |
| 234 | |
| 235 | cfg := config.Default() |
| 236 | cfg.DefaultModel = "good/good-model" |
| 237 | cfg.Desktop.ProviderAccess = []string{"removed", "good"} |
| 238 | cfg.Providers = []config.ProviderEntry{ |
| 239 | {Name: "removed", Kind: "openai", BaseURL: "https://removed.example.invalid/v1", Model: "vision-model", APIKeyEnv: "REMOVED_KEY", Vision: true}, |
| 240 | {Name: "good", Kind: "openai", BaseURL: "https://good.example.invalid/v1", Model: "good-model", APIKeyEnv: "GOOD_KEY"}, |
| 241 | } |
| 242 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 243 | t.Fatalf("save config: %v", err) |
| 244 | } |
| 245 | activeRoot := t.TempDir() |
| 246 | backgroundRoot := t.TempDir() |
| 247 | if err := os.WriteFile(filepath.Join(backgroundRoot, "reasonix.toml"), []byte("[agent]\nsubagent_model = \"removed/vision-model\"\n"), 0o600); err != nil { |
| 248 | t.Fatalf("write project config: %v", err) |
| 249 | } |
| 250 | |
| 251 | app := NewApp() |
| 252 | app.ctx = context.Background() |
| 253 | app.readyHook = func() {} |
| 254 | newTab := func(id, root string) (*WorkspaceTab, *blockingSnapshotCtrl) { |
| 255 | old := newBlockingSnapshotCtrl(control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard})) |
| 256 | close(old.releaseSnapshot) |
| 257 | tab := &WorkspaceTab{ |
| 258 | ID: id, Scope: "project", WorkspaceRoot: root, Ready: true, Ctrl: old, |
| 259 | model: cfg.DefaultModel, Label: cfg.DefaultModel, |
| 260 | sink: &tabEventSink{tabID: id, app: app}, disabledMCP: map[string]ServerView{}, |
| 261 | } |
| 262 | return tab, old |
| 263 | } |
| 264 | active, oldActive := newTab("active", activeRoot) |
| 265 | background, oldBackground := newTab("background", backgroundRoot) |
| 266 | app.tabs = map[string]*WorkspaceTab{active.ID: active, background.ID: background} |
| 267 | app.tabOrder = []string{active.ID, background.ID} |
| 268 | app.activeTabID = active.ID |
| 269 | t.Cleanup(func() { |
| 270 | for _, tab := range []*WorkspaceTab{active, background} { |
| 271 | if tab.Ctrl != nil { |
| 272 | tab.Ctrl.Close() |
| 273 | } |
| 274 | tab.releaseSessionLease() |
| 275 | } |
| 276 | }) |
| 277 | |
| 278 | if err := app.DeleteProvider("removed"); err != nil { |
| 279 | t.Fatalf("DeleteProvider: %v", err) |
| 280 | } |
| 281 | if active.Ctrl != oldActive || background.Ctrl != oldBackground || oldActive.closeCount.Load() != 0 || oldBackground.closeCount.Load() != 0 { |
| 282 | t.Fatalf("saving provider removal replaced a workspace runtime: active=%T/%d background=%T/%d", active.Ctrl, oldActive.closeCount.Load(), background.Ctrl, oldBackground.closeCount.Load()) |
| 283 | } |
| 284 | projectRaw, err := os.ReadFile(filepath.Join(backgroundRoot, "reasonix.toml")) |
| 285 | if err != nil { |
| 286 | t.Fatalf("read project config: %v", err) |
| 287 | } |
| 288 | if !strings.Contains(string(projectRaw), "removed/vision-model") { |
| 289 | t.Fatalf("global provider deletion rewrote project-owned model reference: %s", projectRaw) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | func TestDeleteProviderPreservesDetachedRuntimeUsingAuxiliaryProvider(t *testing.T) { |
| 294 | isolateDesktopUserDirs(t) |
| 295 | setDesktopTestCredential(t, "REMOVED_KEY", "sk-test") |
| 296 | setDesktopTestCredential(t, "GOOD_KEY", "sk-test") |
| 297 | |
| 298 | cfg := config.Default() |
| 299 | cfg.DefaultModel = "good/good-model" |
| 300 | cfg.Agent.SubagentModel = "removed/vision-model" |
| 301 | cfg.Desktop.ProviderAccess = []string{"removed", "good"} |
| 302 | cfg.Providers = []config.ProviderEntry{ |
| 303 | {Name: "removed", Kind: "openai", BaseURL: "https://removed.example.invalid/v1", Model: "vision-model", APIKeyEnv: "REMOVED_KEY", Vision: true}, |
| 304 | {Name: "good", Kind: "openai", BaseURL: "https://good.example.invalid/v1", Model: "good-model", APIKeyEnv: "GOOD_KEY"}, |
| 305 | } |
| 306 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 307 | t.Fatalf("save config: %v", err) |
| 308 | } |
| 309 | |
| 310 | app := NewApp() |
| 311 | app.ctx = context.Background() |
| 312 | detachedCtrl := control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard}) |
| 313 | detached := &WorkspaceTab{ID: "detached", Scope: "global", Ctrl: detachedCtrl, model: cfg.DefaultModel} |
| 314 | app.detachedSessions = map[string]*WorkspaceTab{detached.ID: detached} |
| 315 | t.Cleanup(detachedCtrl.Close) |
| 316 | |
| 317 | err := app.DeleteProvider("removed") |
| 318 | if err != nil || detached.Ctrl != detachedCtrl { |
| 319 | t.Fatalf("DeleteProvider interrupted detached runtime: %v", err) |
| 320 | } |
| 321 | got := config.LoadForEdit(config.UserConfigPath()) |
| 322 | if _, ok := got.Provider("removed"); ok || got.Agent.SubagentModel != "good" { |
| 323 | t.Fatalf("removal was not committed: provider=%v subagent_model=%q", ok, got.Agent.SubagentModel) |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | func TestDeleteProviderPreservesLiveHistoryAndSharedHost(t *testing.T) { |
| 328 | isolateDesktopUserDirs(t) |
| 329 | setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test") |
| 330 | |
| 331 | cfg := config.Default() |
| 332 | cfg.DefaultModel = "prov-a/model-a" |
| 333 | cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"} |
| 334 | cfg.Providers = []config.ProviderEntry{ |
| 335 | {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.invalid/v1", Model: "model-a", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 336 | {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.invalid/v1", Model: "model-b", APIKeyEnv: "REASONIX_TEST_KEY"}, |
| 337 | } |
| 338 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 339 | t.Fatalf("save config: %v", err) |
| 340 | } |
| 341 | |
| 342 | dir := config.SessionDir() |
| 343 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 344 | t.Fatalf("mkdir session dir: %v", err) |
| 345 | } |
| 346 | path := filepath.Join(dir, "provider-removal-success.jsonl") |
| 347 | session := agent.NewSession("system") |
| 348 | session.Add(provider.Message{Role: provider.RoleUser, Content: "preserve this history"}) |
| 349 | exec := agent.New(nil, nil, session, agent.Options{}, event.Discard) |
| 350 | |
| 351 | app := NewApp() |
| 352 | app.ctx = context.Background() |
| 353 | app.readyHook = func() {} |
| 354 | hostKey := "provider-removal-success-host" |
| 355 | host := app.acquireSharedHost(hostKey) |
| 356 | old := newBlockingSnapshotCtrl(control.New(control.Options{ |
| 357 | Executor: exec, SessionDir: dir, SessionPath: path, Label: cfg.DefaultModel, Host: host, Sink: event.Discard, |
| 358 | })) |
| 359 | close(old.releaseSnapshot) |
| 360 | tab := &WorkspaceTab{ |
| 361 | ID: "active", Scope: "global", Ready: true, Ctrl: old, SessionPath: path, |
| 362 | model: cfg.DefaultModel, Label: cfg.DefaultModel, SharedHostKey: hostKey, disabledMCP: map[string]ServerView{}, |
| 363 | } |
| 364 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 365 | app.tabOrder = []string{tab.ID} |
| 366 | app.activeTabID = tab.ID |
| 367 | t.Cleanup(func() { |
| 368 | if tab.Ctrl != nil { |
| 369 | tab.Ctrl.Close() |
| 370 | } |
| 371 | tab.releaseSessionLease() |
| 372 | app.releaseSharedHost(hostKey) |
| 373 | }) |
| 374 | |
| 375 | if err := app.DeleteProvider("prov-a"); err != nil { |
| 376 | t.Fatalf("DeleteProvider: %v", err) |
| 377 | } |
| 378 | if tab.Ctrl != old || old.closeCount.Load() != 0 { |
| 379 | t.Fatalf("saving removal replaced current controller = %T, old closes = %d", tab.Ctrl, old.closeCount.Load()) |
| 380 | } |
| 381 | if tab.model != cfg.DefaultModel || tab.Label != cfg.DefaultModel { |
| 382 | t.Fatalf("saving removal changed current identity = model:%q label:%q", tab.model, tab.Label) |
| 383 | } |
| 384 | if !sameDesktopPath(tab.Ctrl.SessionPath(), path) || !sameDesktopPath(tab.SessionPath, path) { |
| 385 | t.Fatalf("replacement session path = ctrl:%q tab:%q, want %q", tab.Ctrl.SessionPath(), tab.SessionPath, path) |
| 386 | } |
| 387 | if tab.Ctrl.Host() != host || tab.SharedHostKey != hostKey { |
| 388 | t.Fatalf("replacement did not reuse shared host: host=%p want=%p key=%q", tab.Ctrl.Host(), host, tab.SharedHostKey) |
| 389 | } |
| 390 | history := tab.Ctrl.History() |
| 391 | preserved := false |
| 392 | for _, message := range history { |
| 393 | if message.Content == "preserve this history" { |
| 394 | preserved = true |
| 395 | break |
| 396 | } |
| 397 | } |
| 398 | if !preserved { |
| 399 | t.Fatalf("replacement history = %+v, want preserved user message", history) |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | func TestRemoveProviderAccessSavesWhilePreservingDetachedRuntime(t *testing.T) { |
| 404 | isolateDesktopUserDirs(t) |
| 405 | setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") |
| 406 | setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test") |
| 407 | |
| 408 | cfg := config.Default() |
| 409 | cfg.DefaultModel = "mimo-pro/mimo-v2.5-pro" |
| 410 | cfg.Desktop.ProviderAccess = []string{"deepseek", "mimo-pro"} |
| 411 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 412 | t.Fatalf("save config: %v", err) |
| 413 | } |
| 414 | |
| 415 | app := NewApp() |
| 416 | detachedCtrl := control.New(control.Options{Label: "deepseek"}) |
| 417 | detached := &WorkspaceTab{ |
| 418 | ID: detachedRuntimeTabID("detached-provider-removal"), Scope: "global", Ready: true, |
| 419 | Ctrl: detachedCtrl, model: "deepseek/deepseek-v4-flash", Label: "deepseek", |
| 420 | } |
| 421 | app.detachedSessions = map[string]*WorkspaceTab{"detached-provider-removal": detached} |
| 422 | t.Cleanup(detachedCtrl.Close) |
| 423 | |
| 424 | err := app.RemoveProviderAccess("deepseek") |
| 425 | if err != nil { |
| 426 | t.Fatalf("RemoveProviderAccess rejected detached runtime: %v", err) |
| 427 | } |
| 428 | got := config.LoadForEdit(config.UserConfigPath()) |
| 429 | if providerAccessSet(got.Desktop.ProviderAccess)["deepseek"] { |
| 430 | t.Fatalf("provider access removal was not committed: %+v", got.Desktop.ProviderAccess) |
| 431 | } |
| 432 | if detached.Ctrl != detachedCtrl || detached.model != "deepseek/deepseek-v4-flash" { |
| 433 | t.Fatalf("detached runtime changed after rejection: ctrl=%T model=%q", detached.Ctrl, detached.model) |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | func TestRemoveProviderAccessesRejectsMixedGroupBeforeMutation(t *testing.T) { |
| 438 | isolateDesktopUserDirs(t) |
| 439 | cfg := config.Default() |
| 440 | cfg.Desktop.ProviderAccess = []string{"deepseek", "custom"} |
| 441 | cfg.Providers = []config.ProviderEntry{ |
| 442 | {Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", Model: "deepseek-v4-flash"}, |
| 443 | {Name: "custom", Kind: "openai", BaseURL: "https://proxy.example/v1", Model: "custom-model"}, |
| 444 | } |
| 445 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 446 | t.Fatalf("save config: %v", err) |
| 447 | } |
| 448 | |
| 449 | if err := NewApp().RemoveProviderAccesses([]string{"deepseek", "custom"}); err == nil { |
| 450 | t.Fatal("RemoveProviderAccesses accepted mixed official and custom providers") |
| 451 | } |
| 452 | got := config.LoadForEdit(config.UserConfigPath()) |
| 453 | access := providerAccessSet(got.Desktop.ProviderAccess) |
| 454 | if !access["deepseek"] || !access["custom"] { |
| 455 | t.Fatalf("provider access was partially mutated after rejection: %+v", got.Desktop.ProviderAccess) |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | func TestProviderAccessFallbackSkipsUnconfiguredProviders(t *testing.T) { |
| 460 | cfg := &config.Config{ |
| 461 | Desktop: config.DesktopConfig{ProviderAccess: []string{"deepseek", "unconfigured", "local"}}, |
| 462 | Providers: []config.ProviderEntry{ |
| 463 | {Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", Model: "deepseek-v4-flash"}, |
| 464 | {Name: "unconfigured", Kind: "openai", BaseURL: "https://api.example.invalid/v1", Model: "remote", APIKeyEnv: "MISSING_API_KEY"}, |
| 465 | {Name: "local", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "local-model"}, |
| 466 | }, |
| 467 | } |
| 468 | |
| 469 | if got := providerAccessFallbackRef(cfg, []string{"deepseek"}); got != "local/local-model" { |
| 470 | t.Fatalf("fallback = %q, want configured local provider", got) |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | func TestProviderRemovalStateFingerprintCoversConfigAndCredentialRevision(t *testing.T) { |
| 475 | cfg := config.Default() |
| 476 | cfg.Providers = []config.ProviderEntry{{ |
| 477 | Name: "candidate", Kind: "openai", BaseURL: "https://example.invalid/v1", |
| 478 | Model: "model-a", APIKeyEnv: "CANDIDATE_API_KEY", |
| 479 | }} |
| 480 | |
| 481 | base := providerRemovalStateFingerprint(cfg, "credential-revision-a") |
| 482 | if got := providerRemovalStateFingerprint(cfg, "credential-revision-a"); got != base { |
| 483 | t.Fatal("unchanged provider removal state produced an unstable fingerprint") |
| 484 | } |
| 485 | if strings.Contains(base, "CANDIDATE_API_KEY") { |
| 486 | t.Fatal("provider removal fingerprint exposed the credential environment name") |
| 487 | } |
| 488 | if got := providerRemovalStateFingerprint(cfg, "credential-revision-b"); got == base { |
| 489 | t.Fatal("credential revision change did not invalidate provider removal fingerprint") |
| 490 | } |
| 491 | cfg.Providers[0].APIKeyEnv = "ROTATED_API_KEY" |
| 492 | if got := providerRemovalStateFingerprint(cfg, "credential-revision-a"); got == base { |
| 493 | t.Fatal("provider configuration change did not invalidate provider removal fingerprint") |
| 494 | } |
| 495 | } |
| 496 | |
| 497 | func TestProviderAccessFallbackSkipsConfiguredProvidersOutsideAccessList(t *testing.T) { |
| 498 | cfg := &config.Config{ |
| 499 | Desktop: config.DesktopConfig{ProviderAccess: []string{"removed", "visible"}}, |
| 500 | Providers: []config.ProviderEntry{ |
| 501 | {Name: "removed", Kind: "openai", BaseURL: "https://removed.example/v1", Model: "removed-model"}, |
| 502 | {Name: "hidden", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "hidden-model"}, |
| 503 | {Name: "visible", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "visible-model"}, |
| 504 | }, |
| 505 | } |
| 506 | |
| 507 | if got := providerAccessFallbackRef(cfg, []string{"removed"}); got != "visible/visible-model" { |
| 508 | t.Fatalf("fallback = %q, want remaining accessed provider", got) |
| 509 | } |
| 510 | } |
| 511 | |
| 512 | func TestDeleteProviderPersistsVisibleFallbackInsteadOfHiddenConfiguredProvider(t *testing.T) { |
| 513 | isolateDesktopUserDirs(t) |
| 514 | cfg := config.Default() |
| 515 | cfg.DefaultModel = "removed/removed-model" |
| 516 | cfg.Agent.PlannerModel = "removed" |
| 517 | cfg.Agent.SubagentModel = "removed/removed-model" |
| 518 | cfg.Agent.SubagentModels = map[string]string{"review": "removed/removed-model"} |
| 519 | cfg.Desktop.ProviderAccess = []string{"removed", "visible"} |
| 520 | cfg.Providers = []config.ProviderEntry{ |
| 521 | {Name: "removed", Kind: "openai", BaseURL: "https://removed.example/v1", Model: "removed-model"}, |
| 522 | {Name: "hidden", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "hidden-model"}, |
| 523 | {Name: "visible", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "visible-model"}, |
| 524 | } |
| 525 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 526 | t.Fatalf("save config: %v", err) |
| 527 | } |
| 528 | |
| 529 | if err := NewApp().DeleteProvider("removed"); err != nil { |
| 530 | t.Fatalf("DeleteProvider: %v", err) |
| 531 | } |
| 532 | |
| 533 | got := config.LoadForEdit(config.UserConfigPath()) |
| 534 | want := "visible" |
| 535 | if got.DefaultModel != want || got.Agent.PlannerModel != want || got.Agent.SubagentModel != want || got.Agent.SubagentModels["review"] != want { |
| 536 | t.Fatalf("persisted refs used a hidden fallback: default=%q planner=%q subagent=%q skills=%+v", got.DefaultModel, got.Agent.PlannerModel, got.Agent.SubagentModel, got.Agent.SubagentModels) |
| 537 | } |
| 538 | if _, ok := got.Provider("hidden"); !ok { |
| 539 | t.Fatal("hidden provider should remain configured even though it is not a removal fallback") |
| 540 | } |
| 541 | } |
| 542 | |
| 543 | func TestDeleteProviderSavesAndBlocksNewRunWhenOnlyHiddenProviderRemains(t *testing.T) { |
| 544 | isolateDesktopUserDirs(t) |
| 545 | cfg := config.Default() |
| 546 | cfg.DefaultModel = "removed/removed-model" |
| 547 | cfg.Desktop.ProviderAccess = []string{"removed"} |
| 548 | cfg.Providers = []config.ProviderEntry{ |
| 549 | {Name: "removed", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1", Model: "removed-model"}, |
| 550 | {Name: "hidden", Kind: "openai", BaseURL: "http://127.0.0.1:11435/v1", Model: "hidden-model"}, |
| 551 | } |
| 552 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 553 | t.Fatalf("save config: %v", err) |
| 554 | } |
| 555 | |
| 556 | err := NewApp().DeleteProvider("removed") |
| 557 | if err != nil { |
| 558 | t.Fatalf("DeleteProvider should save even without a visible fallback: %v", err) |
| 559 | } |
| 560 | |
| 561 | got := config.LoadForEdit(config.UserConfigPath()) |
| 562 | if got.DefaultModel != "" { |
| 563 | t.Fatalf("default model = %q, want no hidden fallback", got.DefaultModel) |
| 564 | } |
| 565 | if len(got.Desktop.ProviderAccess) != 0 { |
| 566 | t.Fatalf("provider access = %+v, want empty", got.Desktop.ProviderAccess) |
| 567 | } |
| 568 | if _, ok := got.Provider("removed"); ok { |
| 569 | t.Fatal("provider was not deleted") |
| 570 | } |
| 571 | if _, ok := got.Provider("hidden"); !ok { |
| 572 | t.Fatal("hidden provider changed despite the rejected operation") |
| 573 | } |
| 574 | if _, err := resolveModelSettingsRuntime(got, cfg.DefaultModel); err == nil { |
| 575 | t.Fatal("new run was allowed to use a hidden fallback") |
| 576 | } |
| 577 | } |
| 578 | |
| 579 | func TestRemoveProviderAccessesSavesAndBlocksNewRunWithoutConfiguredFallback(t *testing.T) { |
| 580 | isolateDesktopUserDirs(t) |
| 581 | setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") |
| 582 | |
| 583 | cfg := config.Default() |
| 584 | cfg.DefaultModel = "deepseek/deepseek-v4-flash" |
| 585 | cfg.Desktop.ProviderAccess = []string{"deepseek", "mimo-pro"} |
| 586 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 587 | t.Fatalf("save config: %v", err) |
| 588 | } |
| 589 | |
| 590 | app := NewApp() |
| 591 | tab := &WorkspaceTab{ID: "deepseek", Scope: "global", model: cfg.DefaultModel} |
| 592 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 593 | app.tabOrder = []string{tab.ID} |
| 594 | app.activeTabID = tab.ID |
| 595 | |
| 596 | err := app.RemoveProviderAccess("deepseek") |
| 597 | if err != nil { |
| 598 | t.Fatalf("RemoveProviderAccess should save without a fallback: %v", err) |
| 599 | } |
| 600 | got := config.LoadForEdit(config.UserConfigPath()) |
| 601 | access := providerAccessSet(got.Desktop.ProviderAccess) |
| 602 | if access["deepseek"] || !access["mimo-pro"] { |
| 603 | t.Fatalf("provider access was not committed: %+v", got.Desktop.ProviderAccess) |
| 604 | } |
| 605 | if got.DefaultModel != "" || tab.model != cfg.DefaultModel { |
| 606 | t.Fatalf("saved/current models are incorrect: config=%q tab=%q", got.DefaultModel, tab.model) |
| 607 | } |
| 608 | if _, err := resolveModelSettingsRuntime(got, tab.model); err == nil { |
| 609 | t.Fatal("new run was allowed without a configured fallback") |
| 610 | } |
| 611 | } |
| 612 | |
| 613 | func TestRemoveProviderAccessesRejectsOfficialProviderChangedBeforeCommit(t *testing.T) { |
| 614 | isolateDesktopUserDirs(t) |
| 615 | setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") |
| 616 | setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test") |
| 617 | |
| 618 | cfg := config.Default() |
| 619 | cfg.DefaultModel = "deepseek/deepseek-v4-flash" |
| 620 | cfg.Desktop.ProviderAccess = []string{"deepseek", "mimo-pro"} |
| 621 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 622 | t.Fatalf("save config: %v", err) |
| 623 | } |
| 624 | |
| 625 | app := NewApp() |
| 626 | ctrl := newBlockingSnapshotCtrl(control.New(control.Options{Label: "deepseek"})) |
| 627 | tab := &WorkspaceTab{ID: "deepseek", Scope: "global", model: cfg.DefaultModel, Ctrl: ctrl} |
| 628 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 629 | app.tabOrder = []string{tab.ID} |
| 630 | app.activeTabID = tab.ID |
| 631 | |
| 632 | fingerprint := app.Settings().ModelSettingsFingerprint |
| 633 | |
| 634 | unlock := config.LockUserConfigEdits() |
| 635 | changed := config.LoadForEdit(config.UserConfigPath()) |
| 636 | provider, ok := changed.Provider("deepseek") |
| 637 | if !ok { |
| 638 | unlock() |
| 639 | t.Fatal("deepseek provider missing") |
| 640 | } |
| 641 | provider.BaseURL = "https://proxy.example/v1" |
| 642 | if err := changed.SaveTo(config.UserConfigPath()); err != nil { |
| 643 | unlock() |
| 644 | t.Fatalf("save overlapping config edit: %v", err) |
| 645 | } |
| 646 | unlock() |
| 647 | result := app.ApplyModelSettings(ModelSettingsChange{Kind: "access_remove", Names: []string{"deepseek"}, RequestID: "remove", ExpectedFingerprint: fingerprint}) |
| 648 | if result.Persisted || len(result.Issues) == 0 { |
| 649 | t.Fatal("RemoveProviderAccess accepted an official provider changed before commit") |
| 650 | } |
| 651 | got := config.LoadForEdit(config.UserConfigPath()) |
| 652 | access := providerAccessSet(got.Desktop.ProviderAccess) |
| 653 | if !access["deepseek"] || !access["mimo-pro"] { |
| 654 | t.Fatalf("provider access changed after rejected overlap: %+v", got.Desktop.ProviderAccess) |
| 655 | } |
| 656 | if ctrl.closeCount.Load() != 0 || tab.Ctrl != ctrl || tab.model != cfg.DefaultModel { |
| 657 | t.Fatalf("runtime mutated after rejected overlap: closes=%d ctrl=%T model=%q", ctrl.closeCount.Load(), tab.Ctrl, tab.model) |
| 658 | } |
| 659 | } |
| 660 | |
| 661 | func TestRemoveProviderAccessesRejectsCredentialChangeBeforeCommit(t *testing.T) { |
| 662 | isolateDesktopUserDirs(t) |
| 663 | setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") |
| 664 | setDesktopTestCredential(t, "MIMO_API_KEY", "old-key") |
| 665 | |
| 666 | cfg := config.Default() |
| 667 | cfg.DefaultModel = "deepseek/deepseek-v4-flash" |
| 668 | cfg.Desktop.ProviderAccess = []string{"deepseek", "mimo-pro"} |
| 669 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 670 | t.Fatalf("save config: %v", err) |
| 671 | } |
| 672 | |
| 673 | app := NewApp() |
| 674 | ctrl := newBlockingSnapshotCtrl(control.New(control.Options{Label: "deepseek"})) |
| 675 | tab := &WorkspaceTab{ID: "deepseek", Scope: "global", model: cfg.DefaultModel, Ctrl: ctrl} |
| 676 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 677 | app.tabOrder = []string{tab.ID} |
| 678 | app.activeTabID = tab.ID |
| 679 | |
| 680 | fingerprint := app.Settings().ModelSettingsFingerprint |
| 681 | setDesktopTestCredential(t, "MIMO_API_KEY", "new-key") |
| 682 | result := app.ApplyModelSettings(ModelSettingsChange{Kind: "access_remove", Names: []string{"deepseek"}, RequestID: "remove", ExpectedFingerprint: fingerprint}) |
| 683 | if result.Persisted || len(result.Issues) == 0 { |
| 684 | t.Fatal("RemoveProviderAccess accepted credentials changed before commit") |
| 685 | } |
| 686 | got := config.LoadForEdit(config.UserConfigPath()) |
| 687 | access := providerAccessSet(got.Desktop.ProviderAccess) |
| 688 | if !access["deepseek"] || !access["mimo-pro"] { |
| 689 | t.Fatalf("provider access changed after rejected credential overlap: %+v", got.Desktop.ProviderAccess) |
| 690 | } |
| 691 | if ctrl.closeCount.Load() != 0 || tab.Ctrl != ctrl || tab.model != cfg.DefaultModel { |
| 692 | t.Fatalf("runtime mutated after rejected credential overlap: closes=%d ctrl=%T model=%q", ctrl.closeCount.Load(), tab.Ctrl, tab.model) |
| 693 | } |
| 694 | } |
| 695 |