返回 DeepSeek-Reasonix
provider_access_removal_test.go
根目录 / desktop / provider_access_removal_test.go
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
695 lines GO