返回 DeepSeek-Reasonix
settings_app_test.go
根目录 / desktop / settings_app_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "reflect"
11 "strings"
12 "testing"
13 "time"
14
15 "reasonix/internal/agent"
16 "reasonix/internal/config"
17 "reasonix/internal/control"
18 "reasonix/internal/event"
19 fileencoding "reasonix/internal/fileutil/encoding"
20 "reasonix/internal/hook"
21 "reasonix/internal/provider"
22 "reasonix/internal/provider/openai"
23 "reasonix/internal/sandbox"
24 )
25
26 func TestWithFreshSystemPromptReplacesExistingSystemMessage(t *testing.T) {
27 msgs := []provider.Message{
28 {Role: provider.RoleSystem, Content: "old", ReasoningContent: "stale", ReasoningSignature: "sig", ToolCalls: []provider.ToolCall{{ID: "call", Name: "noop"}}, ToolCallID: "tool", Name: "name"},
29 {Role: provider.RoleUser, Content: "hello"},
30 }
31
32 got := withFreshSystemPrompt(msgs, "new")
33 if got[0].Content != "new" {
34 t.Fatalf("system prompt = %q, want new", got[0].Content)
35 }
36 if got[0].ReasoningContent != "" || got[0].ReasoningSignature != "" || len(got[0].ToolCalls) != 0 || got[0].ToolCallID != "" || got[0].Name != "" {
37 t.Fatalf("system metadata should be cleared, got %+v", got[0])
38 }
39 if got[1].Content != "hello" {
40 t.Fatalf("non-system message changed: %+v", got[1])
41 }
42 if msgs[0].Content != "old" {
43 t.Fatalf("input slice was mutated: %+v", msgs[0])
44 }
45 }
46
47 func TestWithFreshSystemPromptPrependsMissingSystemMessage(t *testing.T) {
48 msgs := []provider.Message{{Role: provider.RoleUser, Content: "hello"}}
49
50 got := withFreshSystemPrompt(msgs, "new")
51 if len(got) != 2 || got[0].Role != provider.RoleSystem || got[0].Content != "new" {
52 t.Fatalf("expected prepended system prompt, got %+v", got)
53 }
54 if got[1].Content != "hello" {
55 t.Fatalf("existing user message changed: %+v", got[1])
56 }
57 }
58
59 func TestProviderViewFromEntry_FiltersNonChatModels(t *testing.T) {
60 p := config.ProviderEntry{
61 Name: "mimo-api",
62 Models: []string{
63 "mimo-v2", "mimo-v2-pro",
64 "mimo-v2-asr", "mimo-v2-tts",
65 "mimo-v2-tts-voiceclone", "mimo-v2-tts-voicedesign",
66 },
67 VisionModels: []string{"mimo-v2", "mimo-v2-asr", "mimo-v2-omni"},
68 }
69 view := providerViewFromEntry(p, true, false)
70 want := []string{"mimo-v2", "mimo-v2-pro"}
71 if !reflect.DeepEqual(view.Models, want) {
72 t.Errorf("ProviderView.Models = %v, want %v", view.Models, want)
73 }
74 if got, want := view.VisionModels, []string{"mimo-v2"}; !reflect.DeepEqual(got, want) {
75 t.Errorf("ProviderView.VisionModels = %v, want %v", got, want)
76 }
77 if !view.VisionModelsSet {
78 t.Fatal("ProviderView.VisionModelsSet = false, want true for configured vision_models")
79 }
80 }
81
82 func TestProviderModelOverridesPreservePerModelContextWindow(t *testing.T) {
83 overrides := map[string]config.ProviderModelOverride{
84 "short-model": {ContextWindow: 32_768},
85 "long-model": {ContextWindow: 1_000_000},
86 "removed": {ContextWindow: 8_192},
87 }
88 models := []string{"short-model", "long-model"}
89
90 view := providerModelOverridesForView(overrides, models)
91 if len(view) != 2 || view[0].Model != "long-model" || view[0].ContextWindow != 1_000_000 || view[1].Model != "short-model" || view[1].ContextWindow != 32_768 {
92 t.Fatalf("provider model override view = %+v", view)
93 }
94
95 view[0].ContextWindow = -1
96 saved := providerModelOverridesForSave(view, models)
97 if _, ok := saved["long-model"]; ok {
98 t.Fatalf("non-positive context-only override should be removed: %+v", saved)
99 }
100 if got := saved["short-model"].ContextWindow; got != 32_768 {
101 t.Fatalf("saved short-model context window = %d, want 32768", got)
102 }
103 }
104
105 func TestProviderViewFromEntry_MigratesProviderWideVision(t *testing.T) {
106 p := config.ProviderEntry{
107 Name: "custom",
108 Models: []string{"text-only", "qwen-vl-plus"},
109 Vision: true,
110 }
111 view := providerViewFromEntry(p, false, true)
112 if got, want := view.VisionModels, []string{"text-only", "qwen-vl-plus"}; !reflect.DeepEqual(got, want) {
113 t.Errorf("ProviderView.VisionModels = %v, want %v", got, want)
114 }
115 if !view.VisionModelsSet {
116 t.Fatal("ProviderView.VisionModelsSet = false, want true for provider-wide vision")
117 }
118 }
119
120 func TestProviderViewFromEntryOffersOnlySafeDeepSeekProtocolUpgrade(t *testing.T) {
121 legacy := config.ProviderEntry{
122 Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com",
123 Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
124 }
125 if view := providerViewFromEntry(legacy, true, true); view.RecommendedUpgradeAvailable {
126 t.Fatal("standard official OpenAI entry still offers the retired protocol upgrade")
127 }
128
129 proxy := legacy
130 proxy.BaseURL = "https://deepseek-proxy.example/v1"
131 if view := providerViewFromEntry(proxy, false, true); view.RecommendedUpgradeAvailable {
132 t.Fatal("proxy entry unexpectedly offered the official protocol upgrade")
133 }
134
135 anthropic := legacy
136 anthropic.Kind = "anthropic"
137 anthropic.BaseURL = "https://api.deepseek.com/anthropic"
138 if view := providerViewFromEntry(anthropic, true, true); view.RecommendedUpgradeAvailable {
139 t.Fatal("already-upgraded entry still offered the protocol upgrade")
140 }
141 }
142
143 func TestProviderViewFromEntryIncludesThinking(t *testing.T) {
144 view := providerViewFromEntry(config.ProviderEntry{
145 Name: "anthropic",
146 Thinking: "ADAPTIVE",
147 }, false, true)
148 if view.Thinking != "adaptive" {
149 t.Fatalf("ProviderView.Thinking = %q, want adaptive", view.Thinking)
150 }
151 }
152
153 func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
154 isolateDesktopUserDirs(t)
155 t.Setenv("TEST_PROVIDER_KEY_SOURCE", "")
156 os.Unsetenv("TEST_PROVIDER_KEY_SOURCE")
157 if _, err := config.SetCredential("TEST_PROVIDER_KEY_SOURCE", "sk-test"); err != nil {
158 t.Fatalf("SetCredential: %v", err)
159 }
160
161 view := providerViewFromEntry(config.ProviderEntry{
162 Name: "custom",
163 APIKeyEnv: "TEST_PROVIDER_KEY_SOURCE",
164 }, false, true)
165 if !view.KeySet {
166 t.Fatal("KeySet = false, want true")
167 }
168 if !view.Configured {
169 t.Fatal("Configured = false, want true from resolved credentials")
170 }
171 if view.KeySource == "" || !strings.Contains(view.KeySource, "credentials") {
172 t.Fatalf("KeySource = %q, want credentials source", view.KeySource)
173 }
174 }
175
176 func TestSettingsExposesEffectiveSandboxWriteRoots(t *testing.T) {
177 home := isolateDesktopUserDirs(t)
178 project := robustTempDir(t)
179 cfg := config.LoadForEdit(config.UserConfigPath())
180 cfg.Sandbox.AllowWrite = []string{
181 "${HOME}/.m2",
182 "${HOME}/.m2/repository",
183 }
184 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
185 t.Fatalf("save config: %v", err)
186 }
187
188 app := NewApp()
189 app.tabs = map[string]*WorkspaceTab{
190 "project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
191 }
192 app.activeTabID = "project"
193
194 got := app.Settings().Sandbox
195 if got.EffectiveWorkspaceRoot != project {
196 t.Fatalf("EffectiveWorkspaceRoot = %q, want %q", got.EffectiveWorkspaceRoot, project)
197 }
198 // Settings expose expanded configured roots; the writer confiner normalizes
199 // separators later when enforcing them.
200 want := []string{
201 project,
202 home + "/.m2",
203 home + "/.m2/repository",
204 }
205 if !reflect.DeepEqual(got.EffectiveWriteRoots, want) {
206 t.Fatalf("EffectiveWriteRoots = %v, want %v", got.EffectiveWriteRoots, want)
207 }
208 if !reflect.DeepEqual(got.AllowWrite, cfg.Sandbox.AllowWrite) {
209 t.Fatalf("AllowWrite = %v, want raw configured paths %v", got.AllowWrite, cfg.Sandbox.AllowWrite)
210 }
211 if got.EffectiveShell == "" {
212 t.Fatal("EffectiveShell is empty")
213 }
214 }
215
216 func TestSandboxEffectiveShellViewLabels(t *testing.T) {
217 cases := []struct {
218 name string
219 shell sandbox.Shell
220 want string
221 }{
222 {"bash", sandbox.Shell{Kind: sandbox.ShellBash, Path: "bash"}, "bash"},
223 {"git bash", sandbox.Shell{Kind: sandbox.ShellBash, Path: `C:\Program Files\Git\bin\bash.exe`}, "git-bash"},
224 {"windows powershell", sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "powershell"}, "powershell"},
225 {"pwsh", sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"}, "pwsh"},
226 }
227 for _, tc := range cases {
228 if got := sandboxEffectiveShellView(tc.shell); got != tc.want {
229 t.Errorf("%s: sandboxEffectiveShellView() = %q, want %q", tc.name, got, tc.want)
230 }
231 }
232 }
233
234 func TestProviderViewFromEntryExposesNoAuthAvailability(t *testing.T) {
235 isolateDesktopUserDirs(t)
236 t.Setenv("LOCAL_API_KEY", "")
237 os.Unsetenv("LOCAL_API_KEY")
238
239 noAuth := providerViewFromEntry(config.ProviderEntry{
240 Name: "local",
241 Kind: "openai",
242 BaseURL: "http://127.0.0.1:23333/v1",
243 Models: []string{"model-a"},
244 }, false, true)
245 if noAuth.RequiresKey {
246 t.Fatal("no-auth provider RequiresKey = true, want false")
247 }
248 if !noAuth.Configured {
249 t.Fatal("no-auth provider Configured = false, want true")
250 }
251 if noAuth.KeySet {
252 t.Fatal("no-auth provider KeySet = true, want false")
253 }
254
255 legacyLoopback := providerViewFromEntry(config.ProviderEntry{
256 Name: "local",
257 Kind: "openai",
258 BaseURL: "http://127.0.0.1:23333/v1",
259 Models: []string{"model-a"},
260 APIKeyEnv: "LOCAL_API_KEY",
261 }, false, true)
262 if legacyLoopback.RequiresKey {
263 t.Fatal("loopback provider with missing legacy key env RequiresKey = true, want false")
264 }
265 if !legacyLoopback.Configured {
266 t.Fatal("loopback provider with missing legacy key env Configured = false, want true")
267 }
268
269 official := providerViewFromEntry(config.ProviderEntry{
270 Name: "deepseek",
271 Kind: "openai",
272 BaseURL: "https://api.deepseek.com",
273 Models: []string{"deepseek-v4-flash"},
274 }, true, true)
275 if !official.RequiresKey {
276 t.Fatal("official provider RequiresKey = false, want true")
277 }
278 if official.Configured {
279 t.Fatal("official provider without key Configured = true, want false")
280 }
281 }
282
283 func TestSetProviderKeyDoesNotWarnWhenProjectEnvAlsoDefinesSavedKey(t *testing.T) {
284 isolateDesktopUserDirs(t)
285 project := t.TempDir()
286 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("TEST_PROVIDER_SHADOW=old-key\n"), 0o600); err != nil {
287 t.Fatal(err)
288 }
289 t.Setenv("TEST_PROVIDER_SHADOW", "")
290 os.Unsetenv("TEST_PROVIDER_SHADOW")
291
292 app := &App{
293 tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}},
294 activeTabID: "project",
295 }
296 seedProviderCredentialReference(t, app, "TEST_PROVIDER_SHADOW")
297 warning, err := app.SetProviderKey("TEST_PROVIDER_SHADOW", "new-key")
298 if err != nil {
299 t.Fatalf("SetProviderKey: %v", err)
300 }
301 if warning != "" {
302 t.Fatalf("SetProviderKey warning = %q, want no warning because provider keys use global credentials only", warning)
303 }
304 data, readErr := os.ReadFile(config.UserCredentialsPath())
305 if readErr != nil {
306 t.Fatalf("read credentials: %v", readErr)
307 }
308 p, _ := config.LoadForEdit(config.UserConfigPath()).Provider("credential-test")
309 if p.APIKeyEnv == "TEST_PROVIDER_SHADOW" || !strings.Contains(string(data), p.APIKeyEnv+"=new-key") {
310 t.Fatal("saved credentials missing new isolated reference")
311 }
312 }
313
314 func TestSetProviderKeyDoesNotWarnWhenEnvironmentAlsoDefinesSavedKey(t *testing.T) {
315 isolateDesktopUserDirs(t)
316 t.Setenv("TEST_PROVIDER_EMPTY_ENV", "")
317
318 app := &App{}
319 seedProviderCredentialReference(t, app, "TEST_PROVIDER_EMPTY_ENV")
320 warning, err := app.SetProviderKey("TEST_PROVIDER_EMPTY_ENV", "new-key")
321 if err != nil {
322 t.Fatalf("SetProviderKey: %v", err)
323 }
324 if warning != "" {
325 t.Fatalf("SetProviderKey warning = %q, want no warning because provider keys use global credentials only", warning)
326 }
327 data, readErr := os.ReadFile(config.UserCredentialsPath())
328 if readErr != nil {
329 t.Fatalf("read credentials: %v", readErr)
330 }
331 p, _ := config.LoadForEdit(config.UserConfigPath()).Provider("credential-test")
332 if p.APIKeyEnv == "TEST_PROVIDER_EMPTY_ENV" || !strings.Contains(string(data), p.APIKeyEnv+"=new-key") {
333 t.Fatal("saved credentials missing new isolated reference")
334 }
335 }
336
337 func TestSetProviderKeyDoesNotWarnWhenEmptyProjectEnvAlsoDefinesSavedKey(t *testing.T) {
338 isolateDesktopUserDirs(t)
339 project := t.TempDir()
340 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("TEST_PROVIDER_EMPTY_PROJECT=\n"), 0o600); err != nil {
341 t.Fatal(err)
342 }
343 t.Setenv("TEST_PROVIDER_EMPTY_PROJECT", "")
344 os.Unsetenv("TEST_PROVIDER_EMPTY_PROJECT")
345
346 app := &App{
347 tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}},
348 activeTabID: "project",
349 }
350 seedProviderCredentialReference(t, app, "TEST_PROVIDER_EMPTY_PROJECT")
351 warning, err := app.SetProviderKey("TEST_PROVIDER_EMPTY_PROJECT", "new-key")
352 if err != nil {
353 t.Fatalf("SetProviderKey: %v", err)
354 }
355 if warning != "" {
356 t.Fatalf("SetProviderKey warning = %q, want no warning because provider keys use global credentials only", warning)
357 }
358 }
359
360 func seedProviderCredentialReference(t *testing.T, app *App, env string) {
361 t.Helper()
362 if err := app.SaveProvider(ProviderView{Name: "credential-test", Kind: "openai", BaseURL: "https://example.invalid/v1", Models: []string{"model"}, APIKeyEnv: env}); err != nil {
363 t.Fatal(err)
364 }
365 }
366
367 func TestFetchProviderModelsFiltersNonChatModels(t *testing.T) {
368 isolateDesktopUserDirs(t)
369 if _, err := config.SetCredential("TEST_PROVIDER_KEY", "test-key"); err != nil {
370 t.Fatalf("SetCredential: %v", err)
371 }
372 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
373 if r.URL.Path != "/models" {
374 http.NotFound(w, r)
375 return
376 }
377 if r.Header.Get("Authorization") != "Bearer test-key" {
378 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
379 return
380 }
381 _ = json.NewEncoder(w).Encode(map[string]any{
382 "object": "list",
383 "data": []map[string]string{
384 {"id": "mimo-v2.5-pro", "object": "model"},
385 {"id": "mimo-v2.5-asr", "object": "model"},
386 {"id": "mimo-v2.5-tts", "object": "model"},
387 },
388 })
389 }))
390 defer srv.Close()
391
392 got, err := NewApp().FetchProviderModels(ProviderView{
393 Name: "mimo-api",
394 BaseURL: srv.URL,
395 APIKeyEnv: "TEST_PROVIDER_KEY",
396 })
397 if err != nil {
398 t.Fatalf("FetchProviderModels: %v", err)
399 }
400 want := []string{"mimo-v2.5-pro"}
401 if !reflect.DeepEqual(got, want) {
402 t.Errorf("FetchProviderModels = %v, want %v", got, want)
403 }
404 }
405
406 func TestFetchProviderModelsUsesSavedCredentialBeforeEnvironment(t *testing.T) {
407 isolateDesktopUserDirs(t)
408 const keyEnv = "TEST_PROVIDER_FETCH_KEY"
409 if _, err := config.SetCredential(keyEnv, "saved-key"); err != nil {
410 t.Fatalf("SetCredential: %v", err)
411 }
412 t.Setenv(keyEnv, "stale-env-key")
413
414 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
415 if r.URL.Path != "/models" {
416 http.NotFound(w, r)
417 return
418 }
419 if r.Header.Get("Authorization") != "Bearer saved-key" {
420 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
421 return
422 }
423 _ = json.NewEncoder(w).Encode(map[string]any{
424 "object": "list",
425 "data": []map[string]string{
426 {"id": "model-a", "object": "model"},
427 },
428 })
429 }))
430 defer srv.Close()
431
432 got, err := NewApp().FetchProviderModels(ProviderView{
433 Name: "custom",
434 BaseURL: srv.URL,
435 APIKeyEnv: keyEnv,
436 })
437 if err != nil {
438 t.Fatalf("FetchProviderModels: %v", err)
439 }
440 if want := []string{"model-a"}; !reflect.DeepEqual(got, want) {
441 t.Errorf("FetchProviderModels = %v, want %v", got, want)
442 }
443 if got := os.Getenv(keyEnv); got != "stale-env-key" {
444 t.Fatalf("process env = %q, want stale env left untouched", got)
445 }
446 }
447
448 func TestFetchAllProviderModelsOmitsFailuresWithoutJSONNulls(t *testing.T) {
449 good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
450 _ = json.NewEncoder(w).Encode(map[string]any{
451 "object": "list",
452 "data": []map[string]string{{"id": "model-a", "object": "model"}},
453 })
454 }))
455 defer good.Close()
456 bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
457 http.Error(w, `{"error":"temporary"}`, http.StatusServiceUnavailable)
458 }))
459 defer bad.Close()
460
461 got := NewApp().FetchAllProviderModels([]ProviderView{
462 {Name: "good", Kind: "openai", BaseURL: good.URL},
463 {Name: "bad", Kind: "openai", BaseURL: bad.URL},
464 })
465 if want := []string{"model-a"}; !reflect.DeepEqual(got["good"], want) {
466 t.Fatalf("good provider models = %v, want %v", got["good"], want)
467 }
468 if _, ok := got["bad"]; ok {
469 t.Fatalf("failed provider unexpectedly present: %#v", got)
470 }
471 raw, err := json.Marshal(got)
472 if err != nil {
473 t.Fatalf("marshal batch result: %v", err)
474 }
475 if strings.Contains(string(raw), "null") {
476 t.Fatalf("batch result contains JSON null: %s", raw)
477 }
478 }
479
480 func TestSaveProviderFiltersNonChatModels(t *testing.T) {
481 isolateDesktopUserDirs(t)
482
483 app := NewApp()
484 if err := app.SaveProvider(ProviderView{
485 Name: "mimo-api",
486 Kind: "openai",
487 BaseURL: "https://api.xiaomimimo.com/v1",
488 Models: []string{"mimo-v2.5-asr", "mimo-v2.5-pro", "mimo-v2.5-tts"},
489 VisionModels: []string{
490 "mimo-v2.5-asr",
491 "mimo-v2.5-pro",
492 "mimo-v2.5-tts",
493 },
494 VisionModelsSet: true,
495 Default: "mimo-v2.5-asr",
496 APIKeyEnv: "MIMO_API_KEY",
497 }); err != nil {
498 t.Fatalf("SaveProvider: %v", err)
499 }
500
501 cfg := config.LoadForEdit(config.UserConfigPath())
502 got, ok := cfg.Provider("mimo-api")
503 if !ok {
504 t.Fatal("saved provider not found")
505 }
506 want := []string{"mimo-v2.5-pro"}
507 if !reflect.DeepEqual(got.ModelList(), want) {
508 t.Errorf("saved provider models = %v, want %v", got.ModelList(), want)
509 }
510 if got.DefaultModel() != "mimo-v2.5-pro" {
511 t.Errorf("saved provider default = %q, want mimo-v2.5-pro", got.DefaultModel())
512 }
513 if got, want := got.VisionModels, []string{"mimo-v2.5-pro"}; !reflect.DeepEqual(got, want) {
514 t.Errorf("saved provider vision_models = %v, want %v", got, want)
515 }
516 raw, err := os.ReadFile(config.UserConfigPath())
517 if err != nil {
518 t.Fatalf("read saved config: %v", err)
519 }
520 saved := string(raw)
521 blockStart := strings.Index(saved, "\n[[providers]]\nname = \"mimo-api\"")
522 if blockStart < 0 {
523 t.Fatalf("saved config missing mimo-api provider block:\n%s", raw)
524 }
525 block := saved[blockStart:]
526 if next := strings.Index(block[len("\n[[providers]]"):], "\n[[providers]]"); next >= 0 {
527 block = block[:len("\n[[providers]]")+next]
528 }
529 if !strings.Contains(block, `models = ["mimo-v2.5-pro"]`) {
530 t.Fatalf("saved provider block did not persist single selection as models array:\n%s", block)
531 }
532 if strings.Contains(block, `model = "mimo-v2.5-pro"`) {
533 t.Fatalf("saved provider block should not persist explicit single selection as legacy model:\n%s", block)
534 }
535 if !strings.Contains(block, `vision_models = ["mimo-v2.5-pro"]`) {
536 t.Fatalf("saved provider block did not persist filtered vision_models:\n%s", block)
537 }
538 }
539
540 func TestSaveProviderModelCatalogsPersistsFreshBatchAtomically(t *testing.T) {
541 isolateDesktopUserDirs(t)
542
543 app := NewApp()
544 providers := []ProviderView{
545 {Name: "batch-a", Kind: "openai", BaseURL: "https://a.example.com/v1", Models: []string{"model-a"}, APIKeyEnv: "BATCH_A_API_KEY", Headers: map[string]string{"X-Tenant": "a"}},
546 {Name: "batch-b", Kind: "openai", BaseURL: "https://b.example.com/v1", Models: []string{"model-b"}, APIKeyEnv: "BATCH_B_API_KEY"},
547 }
548 for _, provider := range providers {
549 if err := app.SaveProvider(provider); err != nil {
550 t.Fatalf("SaveProvider(%s): %v", provider.Name, err)
551 }
552 }
553
554 cfg := config.LoadForEdit(config.UserConfigPath())
555 a, _ := cfg.Provider("batch-a")
556 b, _ := cfg.Provider("batch-b")
557 updates := []ProviderModelCatalogUpdate{
558 {Name: "batch-a", ExpectedFingerprint: providerModelCatalogFingerprint(*a), Models: []string{"model-a", "model-a-new"}, Default: "model-a-new"},
559 {Name: "batch-b", ExpectedFingerprint: providerModelCatalogFingerprint(*b), Models: []string{"model-b", "model-b-new"}, Default: "model-b-new"},
560 }
561 applied, err := app.SaveProviderModelCatalogs(updates)
562 if err != nil {
563 t.Fatalf("SaveProviderModelCatalogs: %v", err)
564 }
565 if !reflect.DeepEqual(applied, []string{"batch-a", "batch-b"}) {
566 t.Fatalf("applied = %v, want both providers", applied)
567 }
568
569 cfg = config.LoadForEdit(config.UserConfigPath())
570 a, _ = cfg.Provider("batch-a")
571 b, _ = cfg.Provider("batch-b")
572 if a.DefaultModel() != "model-a-new" || b.DefaultModel() != "model-b-new" {
573 t.Fatalf("catalog defaults = %q/%q, want model-a-new/model-b-new", a.DefaultModel(), b.DefaultModel())
574 }
575 if a.BaseURL != providers[0].BaseURL || a.APIKeyEnv != providers[0].APIKeyEnv || a.Headers["X-Tenant"] != "a" {
576 t.Fatalf("narrow catalog update changed provider identity: %+v", *a)
577 }
578
579 aFingerprint := providerModelCatalogFingerprint(*a)
580 bFingerprint := providerModelCatalogFingerprint(*b)
581 if _, err := app.SaveProviderModelCatalogs([]ProviderModelCatalogUpdate{
582 {Name: "batch-a", ExpectedFingerprint: aFingerprint, Models: []string{"must-not-persist"}},
583 {Name: "batch-b", ExpectedFingerprint: bFingerprint, Models: []string{"text-embedding-3-small"}},
584 }); err == nil {
585 t.Fatal("SaveProviderModelCatalogs invalid batch returned nil error")
586 }
587 cfg = config.LoadForEdit(config.UserConfigPath())
588 a, _ = cfg.Provider("batch-a")
589 if a.DefaultModel() == "must-not-persist" {
590 t.Fatal("SaveProviderModelCatalogs persisted a partial invalid batch")
591 }
592 }
593
594 func TestSaveProviderModelCatalogsRejectsStaleCompletion(t *testing.T) {
595 isolateDesktopUserDirs(t)
596
597 app := NewApp()
598 if err := app.SaveProvider(ProviderView{
599 Name: "race-provider", Kind: "openai", BaseURL: "https://old.example.com/v1",
600 Models: []string{"old-model"}, Default: "old-model", APIKeyEnv: "OLD_API_KEY",
601 Headers: map[string]string{"X-Version": "old"},
602 }); err != nil {
603 t.Fatalf("SaveProvider(old): %v", err)
604 }
605 cfg := config.LoadForEdit(config.UserConfigPath())
606 old, _ := cfg.Provider("race-provider")
607 oldFingerprint := providerModelCatalogFingerprint(*old)
608
609 started := make(chan struct{})
610 release := make(chan struct{})
611 type result struct {
612 applied []string
613 err error
614 }
615 done := make(chan result, 1)
616 go func() {
617 close(started)
618 <-release
619 applied, err := app.SaveProviderModelCatalogs([]ProviderModelCatalogUpdate{{
620 Name: "race-provider", ExpectedFingerprint: oldFingerprint,
621 Models: []string{"old-model", "stale-fetched-model"}, Default: "stale-fetched-model",
622 }})
623 done <- result{applied: applied, err: err}
624 }()
625 <-started
626
627 if err := app.SaveProvider(ProviderView{
628 Name: "race-provider", Kind: "openai", BaseURL: "https://new.example.com/v1",
629 Models: []string{"new-model"}, Default: "new-model", APIKeyEnv: "NEW_API_KEY",
630 Headers: map[string]string{"X-Version": "new"},
631 }); err != nil {
632 t.Fatalf("SaveProvider(new): %v", err)
633 }
634 close(release)
635 gotResult := <-done
636 if gotResult.err != nil {
637 t.Fatalf("stale SaveProviderModelCatalogs: %v", gotResult.err)
638 }
639 if len(gotResult.applied) != 0 {
640 t.Fatalf("stale update applied providers %v, want none", gotResult.applied)
641 }
642
643 cfg = config.LoadForEdit(config.UserConfigPath())
644 got, _ := cfg.Provider("race-provider")
645 if got.BaseURL != "https://new.example.com/v1" || got.APIKeyEnv != "NEW_API_KEY" || got.Headers["X-Version"] != "new" {
646 t.Fatalf("stale completion overwrote provider identity: %+v", *got)
647 }
648 if !reflect.DeepEqual(got.ChatModelList(), []string{"new-model"}) || got.DefaultModel() != "new-model" {
649 t.Fatalf("stale completion overwrote model selection: models=%v default=%q", got.ChatModelList(), got.DefaultModel())
650 }
651 }
652
653 func TestSaveProviderModelCatalogsRejectsStaleCredentialSnapshot(t *testing.T) {
654 isolateDesktopUserDirs(t)
655
656 app := NewApp()
657 if err := app.SaveProvider(ProviderView{
658 Name: "credential-race", Kind: "openai", BaseURL: "https://credential.example.com/v1",
659 Models: []string{"current-model"}, APIKeyEnv: "CREDENTIAL_RACE_API_KEY",
660 }); err != nil {
661 t.Fatalf("SaveProvider: %v", err)
662 }
663 if _, err := app.SaveProviderKey("CREDENTIAL_RACE_API_KEY", "old-key"); err != nil {
664 t.Fatalf("SaveProviderKey(old): %v", err)
665 }
666 cfg := config.LoadForEdit(config.UserConfigPath())
667 provider, _ := cfg.Provider("credential-race")
668 oldFingerprint := providerModelCatalogFingerprint(*provider)
669
670 if _, err := app.SetConnectionKey("credential-race", "new-key-with-different-length"); err != nil {
671 t.Fatalf("SaveProviderKey(new): %v", err)
672 }
673 applied, err := app.SaveProviderModelCatalogs([]ProviderModelCatalogUpdate{{
674 Name: "credential-race", ExpectedFingerprint: oldFingerprint,
675 Models: []string{"current-model", "stale-key-model"}, Default: "stale-key-model",
676 }})
677 if err != nil {
678 t.Fatalf("SaveProviderModelCatalogs: %v", err)
679 }
680 if len(applied) != 0 {
681 t.Fatalf("credential-stale update applied providers %v, want none", applied)
682 }
683 cfg = config.LoadForEdit(config.UserConfigPath())
684 provider, _ = cfg.Provider("credential-race")
685 if !reflect.DeepEqual(provider.ChatModelList(), []string{"current-model"}) {
686 t.Fatalf("credential-stale update overwrote models: %v", provider.ChatModelList())
687 }
688 }
689
690 func TestSaveProviderModelCatalogsRejectsOverlappingCredentialRotation(t *testing.T) {
691 isolateDesktopUserDirs(t)
692
693 app := NewApp()
694 const keyEnv = "CREDENTIAL_OVERLAP_API_KEY"
695 if err := app.SaveProvider(ProviderView{
696 Name: "credential-overlap", Kind: "openai", BaseURL: "https://credential.example.com/v1",
697 Models: []string{"current-model"}, APIKeyEnv: keyEnv,
698 }); err != nil {
699 t.Fatalf("SaveProvider: %v", err)
700 }
701 if _, err := app.SaveProviderKey(keyEnv, "old-key"); err != nil {
702 t.Fatalf("SaveProviderKey(old): %v", err)
703 }
704 cfg := config.LoadForEdit(config.UserConfigPath())
705 provider, _ := cfg.Provider("credential-overlap")
706 oldFingerprint := providerModelCatalogFingerprint(*provider)
707
708 snapshotRead := make(chan struct{})
709 releaseApply := make(chan struct{})
710 app.providerCatalogBeforeCredentialLockHook = func(string) {
711 close(snapshotRead)
712 <-releaseApply
713 }
714 type result struct {
715 applied []string
716 err error
717 }
718 catalogDone := make(chan result, 1)
719 go func() {
720 applied, err := app.SaveProviderModelCatalogs([]ProviderModelCatalogUpdate{{
721 Name: "credential-overlap", ExpectedFingerprint: oldFingerprint,
722 Models: []string{"current-model", "stale-key-model"}, Default: "stale-key-model",
723 }})
724 catalogDone <- result{applied: applied, err: err}
725 }()
726 <-snapshotRead
727
728 // Keep the replacement the same length as the old value: revision safety
729 // must come from credential contents and locking, not size or mtime luck.
730 if _, err := config.SetCredential(provider.APIKeyEnv, "new-key"); err != nil {
731 close(releaseApply)
732 t.Fatalf("external credential rotation: %v", err)
733 }
734 close(releaseApply)
735 gotResult := <-catalogDone
736 if gotResult.err != nil {
737 t.Fatalf("SaveProviderModelCatalogs: %v", gotResult.err)
738 }
739 if len(gotResult.applied) != 0 {
740 t.Fatalf("credential-stale update applied providers %v, want none", gotResult.applied)
741 }
742
743 cfg = config.LoadForEdit(config.UserConfigPath())
744 provider, _ = cfg.Provider("credential-overlap")
745 if !reflect.DeepEqual(provider.ChatModelList(), []string{"current-model"}) {
746 t.Fatalf("overlapping credential rotation persisted stale models: %v", provider.ChatModelList())
747 }
748 }
749
750 func TestSaveProviderPersistsThinkingOverride(t *testing.T) {
751 isolateDesktopUserDirs(t)
752
753 app := NewApp()
754 if err := app.SaveProvider(ProviderView{
755 Name: "glm-proxy",
756 Kind: "openai",
757 BaseURL: "https://proxy.example.com/v1",
758 Models: []string{"glm-4.5-air"},
759 APIKeyEnv: "GLM_PROXY_API_KEY",
760 Thinking: "DISABLED",
761 }); err != nil {
762 t.Fatalf("SaveProvider: %v", err)
763 }
764
765 cfg := config.LoadForEdit(config.UserConfigPath())
766 got, ok := cfg.Provider("glm-proxy")
767 if !ok {
768 t.Fatal("saved provider not found")
769 }
770 if got.Thinking != "disabled" {
771 t.Fatalf("saved provider thinking = %q, want disabled", got.Thinking)
772 }
773 }
774
775 func TestSaveProviderPersistsAuthHeader(t *testing.T) {
776 isolateDesktopUserDirs(t)
777
778 app := NewApp()
779 if err := app.SaveProvider(ProviderView{
780 Name: "minimax-global-anthropic",
781 Kind: "anthropic",
782 BaseURL: "https://api.minimax.io/anthropic",
783 Models: []string{"MiniMax-M3"},
784 APIKeyEnv: "MINIMAX_API_KEY",
785 AuthHeader: true,
786 }); err != nil {
787 t.Fatalf("SaveProvider: %v", err)
788 }
789
790 cfg := config.LoadForEdit(config.UserConfigPath())
791 got, ok := cfg.Provider("minimax-global-anthropic")
792 if !ok {
793 t.Fatal("saved provider not found")
794 }
795 if !got.AuthHeader {
796 t.Fatal("saved provider auth_header = false, want true")
797 }
798 view := providerViewFromEntry(*got, false, true)
799 if !view.AuthHeader {
800 t.Fatal("provider view authHeader = false, want true")
801 }
802 }
803
804 func TestSaveProviderPersistsAndMirrorsCustomEndpointURLs(t *testing.T) {
805 isolateDesktopUserDirs(t)
806
807 app := NewApp()
808 if err := app.SaveProvider(ProviderView{
809 Name: "sub2api",
810 Kind: "openai",
811 BaseURL: "https://proxy.example.com/v1",
812 ChatURL: " https://legacy.example.com/chat/completions/ ",
813 RequestURL: " https://proxy.example.com/custom/chat/completions/?token=1 ",
814 ModelsURL: " https://proxy.example.com/v1/models ",
815 Models: []string{"model-a"},
816 Default: "model-a",
817 APIKeyEnv: "SUB2API_KEY",
818 }); err != nil {
819 t.Fatalf("SaveProvider: %v", err)
820 }
821
822 cfg := config.LoadForEdit(config.UserConfigPath())
823 got, ok := cfg.Provider("sub2api")
824 if !ok {
825 t.Fatal("saved provider not found")
826 }
827 if got.ChatURL != "https://proxy.example.com/custom/chat/completions/?token=1" {
828 t.Fatalf("saved chat_url = %q", got.ChatURL)
829 }
830 if got.RequestURL != "https://proxy.example.com/custom/chat/completions/?token=1" {
831 t.Fatalf("saved request_url = %q", got.RequestURL)
832 }
833 if got.ModelsURL != "https://proxy.example.com/v1/models" {
834 t.Fatalf("saved models_url = %q", got.ModelsURL)
835 }
836
837 view := app.Settings()
838 for _, provider := range view.Providers {
839 if provider.Name != "sub2api" {
840 continue
841 }
842 if provider.ChatURL != "https://proxy.example.com/custom/chat/completions/?token=1" {
843 t.Fatalf("Settings chatUrl = %q", provider.ChatURL)
844 }
845 if provider.RequestURL != "https://proxy.example.com/custom/chat/completions/?token=1" {
846 t.Fatalf("Settings requestUrl = %q", provider.RequestURL)
847 }
848 if provider.ModelsURL != "https://proxy.example.com/v1/models" {
849 t.Fatalf("Settings modelsUrl = %q", provider.ModelsURL)
850 }
851 return
852 }
853 t.Fatalf("Settings providers missing sub2api: %+v", view.Providers)
854 }
855
856 func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
857 isolateDesktopUserDirs(t)
858
859 cfg := config.LoadForEdit(config.UserConfigPath())
860 cfg.Providers = []config.ProviderEntry{{
861 Name: "custom",
862 Kind: "openai",
863 BaseURL: "https://proxy.example.com/v1",
864 Models: []string{"model-a", "model-b"},
865 Default: "model-a",
866 APIKeyEnv: "CUSTOM_API_KEY",
867 Price: &provider.Pricing{Input: 1, Output: 2, Currency: "$"},
868 Prices: map[string]*provider.Pricing{"model-b": {Input: 3, Output: 4, Currency: "$"}},
869 Thinking: "adaptive",
870 Effort: "high",
871 VisionDetail: "low",
872 ExtraBody: map[string]any{"enable_thinking": true},
873 NoProxy: true,
874 }}
875 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
876 t.Fatalf("SaveTo: %v", err)
877 }
878
879 app := NewApp()
880 settings := app.Settings()
881 var view ProviderView
882 found := false
883 for _, p := range settings.Providers {
884 if p.Name == "custom" {
885 view = p
886 found = true
887 break
888 }
889 }
890 if !found {
891 t.Fatalf("Settings providers missing custom: %+v", settings.Providers)
892 }
893 if view.ExtraBody["enable_thinking"] != true {
894 t.Fatalf("settings extra_body = %+v, want enable_thinking=true", view.ExtraBody)
895 }
896
897 if err := app.SaveProvider(view); err != nil {
898 t.Fatalf("SaveProvider: %v", err)
899 }
900
901 gotCfg := config.LoadForEdit(config.UserConfigPath())
902 got, ok := gotCfg.Provider("custom")
903 if !ok {
904 t.Fatal("saved provider not found")
905 }
906 if got.Price == nil || got.Price.Input != 1 || got.Price.Output != 2 || got.Price.Currency != "$" {
907 t.Fatalf("provider-wide price = %+v, want preserved", got.Price)
908 }
909 if got.Prices["model-b"] == nil || got.Prices["model-b"].Input != 3 || got.Prices["model-b"].Output != 4 || got.Prices["model-b"].Currency != "$" {
910 t.Fatalf("per-model prices = %+v, want model-b price preserved", got.Prices)
911 }
912 if got.Thinking != "adaptive" || got.Effort != "high" {
913 t.Fatalf("thinking/effort = %q/%q, want adaptive/high", got.Thinking, got.Effort)
914 }
915 if got.VisionDetail != "low" {
916 t.Fatalf("vision_detail = %q, want low", got.VisionDetail)
917 }
918 if got.ExtraBody["enable_thinking"] != true {
919 t.Fatalf("extra_body = %+v, want enable_thinking=true", got.ExtraBody)
920 }
921 if !got.NoProxy {
922 t.Fatal("no_proxy = false, want preserved true")
923 }
924 }
925
926 func TestSaveProviderClearsProviderWideVisionForPerModelSelection(t *testing.T) {
927 isolateDesktopUserDirs(t)
928
929 cfg := config.LoadForEdit(config.UserConfigPath())
930 cfg.Providers = []config.ProviderEntry{{
931 Name: "custom",
932 Kind: "openai",
933 BaseURL: "https://proxy.example.com/v1",
934 Models: []string{"text-only", "qwen-vl-plus"},
935 Default: "text-only",
936 Vision: true,
937 }}
938 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
939 t.Fatalf("SaveTo: %v", err)
940 }
941
942 if err := NewApp().SaveProvider(ProviderView{
943 Name: "custom",
944 Kind: "openai",
945 BaseURL: "https://proxy.example.com/v1",
946 Models: []string{"text-only", "qwen-vl-plus"},
947 VisionModels: []string{"qwen-vl-plus"},
948 VisionModelsSet: true,
949 Default: "text-only",
950 }); err != nil {
951 t.Fatalf("SaveProvider: %v", err)
952 }
953
954 gotCfg := config.LoadForEdit(config.UserConfigPath())
955 got, ok := gotCfg.Provider("custom")
956 if !ok {
957 t.Fatal("saved provider not found")
958 }
959 if got.Vision {
960 t.Fatal("saved provider kept provider-wide vision=true")
961 }
962 if got, want := got.VisionModels, []string{"qwen-vl-plus"}; !reflect.DeepEqual(got, want) {
963 t.Fatalf("saved provider vision_models = %v, want %v", got, want)
964 }
965 textOnly := *got
966 textOnly.Model = "text-only"
967 if config.EffectiveVision(&textOnly) {
968 t.Fatal("unchecked text-only model should not inherit image input")
969 }
970 vision := *got
971 vision.Model = "qwen-vl-plus"
972 if !config.EffectiveVision(&vision) {
973 t.Fatal("checked vision model should keep image input")
974 }
975 }
976
977 func TestSaveProviderPreservesExplicitEmptyVisionModels(t *testing.T) {
978 isolateDesktopUserDirs(t)
979
980 if err := NewApp().SaveProvider(ProviderView{
981 Name: "custom",
982 Kind: "openai",
983 BaseURL: "https://proxy.example.com/v1",
984 Models: []string{"text-only", "qwen-vl-plus"},
985 VisionModels: []string{},
986 VisionModelsSet: true,
987 Default: "text-only",
988 }); err != nil {
989 t.Fatalf("SaveProvider: %v", err)
990 }
991
992 cfg := config.LoadForEdit(config.UserConfigPath())
993 got, ok := cfg.Provider("custom")
994 if !ok {
995 t.Fatal("saved provider not found")
996 }
997 if got.VisionModels == nil || len(got.VisionModels) != 0 {
998 t.Fatalf("saved provider vision_models = %#v, want explicit empty list", got.VisionModels)
999 }
1000 raw, err := os.ReadFile(config.UserConfigPath())
1001 if err != nil {
1002 t.Fatalf("read saved config: %v", err)
1003 }
1004 if !strings.Contains(string(raw), `vision_models = []`) {
1005 t.Fatalf("saved config did not persist explicit empty vision_models:\n%s", raw)
1006 }
1007 }
1008
1009 func TestSaveProviderPersistsWebSearchOn(t *testing.T) {
1010 isolateDesktopUserDirs(t)
1011
1012 if err := NewApp().SaveProvider(ProviderView{
1013 Name: "deepseek-responses",
1014 Kind: "responses",
1015 BaseURL: "https://api.deepseek.com",
1016 Models: []string{"deepseek-v4-flash"},
1017 Default: "deepseek-v4-flash",
1018 WebSearch: true,
1019 }); err != nil {
1020 t.Fatalf("SaveProvider: %v", err)
1021 }
1022
1023 cfg := config.LoadForEdit(config.UserConfigPath())
1024 got, ok := cfg.Provider("deepseek-responses")
1025 if !ok || got.WebSearch == nil || !*got.WebSearch {
1026 t.Fatalf("saved provider = %+v, found=%v; want web_search=true", got, ok)
1027 }
1028 raw, err := os.ReadFile(config.UserConfigPath())
1029 if err != nil {
1030 t.Fatalf("read saved config: %v", err)
1031 }
1032 if !strings.Contains(string(raw), "web_search = true") {
1033 t.Fatalf("saved config did not persist web_search:\n%s", raw)
1034 }
1035 }
1036
1037 func TestSaveProviderPersistsExplicitWebSearchOff(t *testing.T) {
1038 isolateDesktopUserDirs(t)
1039
1040 if err := NewApp().SaveProvider(ProviderView{
1041 Name: "deepseek-responses",
1042 Kind: "responses",
1043 BaseURL: "https://api.deepseek.com",
1044 Models: []string{"deepseek-v4-flash"},
1045 Default: "deepseek-v4-flash",
1046 WebSearch: false,
1047 }); err != nil {
1048 t.Fatalf("SaveProvider: %v", err)
1049 }
1050
1051 cfg := config.LoadForEdit(config.UserConfigPath())
1052 got, ok := cfg.Provider("deepseek-responses")
1053 if !ok || got.WebSearch == nil || *got.WebSearch || config.EffectiveWebSearch(got) {
1054 t.Fatalf("saved provider = %+v, found=%v; want explicit web_search=false", got, ok)
1055 }
1056 raw, err := os.ReadFile(config.UserConfigPath())
1057 if err != nil {
1058 t.Fatalf("read saved config: %v", err)
1059 }
1060 if !strings.Contains(string(raw), "web_search = false") {
1061 t.Fatalf("saved config did not persist web_search=false:\n%s", raw)
1062 }
1063 }
1064
1065 func TestSetProviderWebSearchUpdatesGroupedDeepSeekAliasesAtomically(t *testing.T) {
1066 isolateDesktopUserDirs(t)
1067
1068 enabled := true
1069 cfg := config.LoadForEdit(config.UserConfigPath())
1070 cfg.Desktop.ProviderAccess = []string{"deepseek-flash", "deepseek-pro"}
1071 cfg.Providers = []config.ProviderEntry{
1072 {
1073 Name: "deepseek-flash", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
1074 Models: []string{"deepseek-v4-flash"}, Headers: map[string]string{"X-Route": "flash"}, WebSearch: &enabled,
1075 },
1076 {
1077 Name: "deepseek-pro", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
1078 Models: []string{"deepseek-v4-pro"}, Headers: map[string]string{"X-Route": "pro"}, WebSearch: &enabled,
1079 },
1080 }
1081 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1082 t.Fatalf("SaveTo: %v", err)
1083 }
1084
1085 if err := NewApp().SetProviderWebSearch([]string{"deepseek-flash", "deepseek-pro", "deepseek-flash"}, false); err != nil {
1086 t.Fatalf("SetProviderWebSearch: %v", err)
1087 }
1088
1089 got := config.LoadForEdit(config.UserConfigPath())
1090 for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
1091 entry, ok := got.Provider(name)
1092 if !ok || entry.WebSearch == nil || *entry.WebSearch {
1093 t.Fatalf("provider %q = %+v, found=%v; want explicit web_search=false", name, entry, ok)
1094 }
1095 }
1096 if flash, _ := got.Provider("deepseek-flash"); flash.Headers["X-Route"] != "flash" {
1097 t.Fatalf("Flash custom transport fields changed: %+v", flash)
1098 }
1099 if pro, _ := got.Provider("deepseek-pro"); pro.Headers["X-Route"] != "pro" {
1100 t.Fatalf("Pro custom transport fields changed: %+v", pro)
1101 }
1102 }
1103
1104 func TestSetProviderWebSearchRejectsWholeGroupBeforeWriting(t *testing.T) {
1105 isolateDesktopUserDirs(t)
1106
1107 enabled := true
1108 cfg := config.LoadForEdit(config.UserConfigPath())
1109 cfg.Providers = []config.ProviderEntry{
1110 {Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", Models: []string{"deepseek-v4-flash"}, WebSearch: &enabled},
1111 {Name: "proxy", Kind: "anthropic", BaseURL: "https://gateway.example/anthropic", Models: []string{"custom-model"}, WebSearch: &enabled},
1112 }
1113 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1114 t.Fatalf("SaveTo: %v", err)
1115 }
1116
1117 if err := NewApp().SetProviderWebSearch([]string{"deepseek", "proxy"}, false); err == nil {
1118 t.Fatal("SetProviderWebSearch accepted an unverified endpoint")
1119 }
1120
1121 got := config.LoadForEdit(config.UserConfigPath())
1122 entry, ok := got.Provider("deepseek")
1123 if !ok || entry.WebSearch == nil || !*entry.WebSearch {
1124 t.Fatalf("official provider was partially updated after group rejection: %+v, found=%v", entry, ok)
1125 }
1126 }
1127
1128 func TestSetProviderWebSearchPreservesEveryVisibleRuntime(t *testing.T) {
1129 isolateDesktopUserDirs(t)
1130 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1131 enabled := true
1132 cfg := config.Default()
1133 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
1134 cfg.Desktop.ProviderAccess = []string{"deepseek"}
1135 cfg.Providers = []config.ProviderEntry{{
1136 Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
1137 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash",
1138 APIKeyEnv: "DEEPSEEK_API_KEY", WebSearch: &enabled,
1139 }}
1140 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1141 t.Fatalf("save config: %v", err)
1142 }
1143
1144 app := NewApp()
1145 app.ctx = context.Background()
1146 app.readyHook = func() {}
1147 newTab := func(id string) (*WorkspaceTab, *blockingSnapshotCtrl) {
1148 old := newBlockingSnapshotCtrl(control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard}))
1149 close(old.releaseSnapshot)
1150 tab := &WorkspaceTab{
1151 ID: id, Scope: "global", Ready: true, Ctrl: old,
1152 model: cfg.DefaultModel, Label: cfg.DefaultModel,
1153 sink: &tabEventSink{tabID: id, app: app}, disabledMCP: map[string]ServerView{},
1154 }
1155 return tab, old
1156 }
1157 first, oldFirst := newTab("first")
1158 second, oldSecond := newTab("second")
1159 app.tabs = map[string]*WorkspaceTab{first.ID: first, second.ID: second}
1160 app.tabOrder = []string{first.ID, second.ID}
1161 app.activeTabID = first.ID
1162 t.Cleanup(func() {
1163 for _, tab := range []*WorkspaceTab{first, second} {
1164 if tab.Ctrl != nil {
1165 tab.Ctrl.Close()
1166 }
1167 tab.releaseSessionLease()
1168 }
1169 })
1170
1171 if err := app.SetProviderWebSearch([]string{"deepseek"}, false); err != nil {
1172 t.Fatalf("SetProviderWebSearch: %v", err)
1173 }
1174 if first.Ctrl != oldFirst || second.Ctrl != oldSecond || oldFirst.closeCount.Load() != 0 || oldSecond.closeCount.Load() != 0 {
1175 t.Fatal("saving web-search capability replaced a visible runtime")
1176 }
1177 got := config.LoadForEdit(config.UserConfigPath())
1178 provider, ok := got.Provider("deepseek")
1179 if !ok || provider.WebSearch == nil || *provider.WebSearch {
1180 t.Fatalf("persisted DeepSeek web_search = %+v, want false", provider)
1181 }
1182 }
1183
1184 func TestSetProviderWebSearchSavesWithDetachedRuntime(t *testing.T) {
1185 isolateDesktopUserDirs(t)
1186 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1187 enabled := true
1188 cfg := config.Default()
1189 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
1190 cfg.Desktop.ProviderAccess = []string{"deepseek"}
1191 cfg.Providers = []config.ProviderEntry{{
1192 Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
1193 Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", WebSearch: &enabled,
1194 }}
1195 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1196 t.Fatalf("save config: %v", err)
1197 }
1198
1199 app := NewApp()
1200 app.ctx = context.Background()
1201 detachedCtrl := control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard})
1202 detached := &WorkspaceTab{ID: "detached", Scope: "global", Ctrl: detachedCtrl, model: cfg.DefaultModel}
1203 app.detachedSessions = map[string]*WorkspaceTab{detached.ID: detached}
1204 t.Cleanup(detachedCtrl.Close)
1205
1206 err := app.SetProviderWebSearch([]string{"deepseek"}, false)
1207 if err != nil || detached.Ctrl != detachedCtrl {
1208 t.Fatalf("SetProviderWebSearch must preserve detached runtime: %v", err)
1209 }
1210 got := config.LoadForEdit(config.UserConfigPath())
1211 provider, ok := got.Provider("deepseek")
1212 if !ok || provider.WebSearch == nil || *provider.WebSearch {
1213 t.Fatalf("web-search mutation was not saved: %+v", provider)
1214 }
1215 }
1216
1217 func TestSaveProviderPreservesHiddenCustomWebSearchOverride(t *testing.T) {
1218 isolateDesktopUserDirs(t)
1219
1220 enabled := true
1221 cfg := config.LoadForEdit(config.UserConfigPath())
1222 cfg.Providers = []config.ProviderEntry{{
1223 Name: "custom-anthropic",
1224 Kind: "anthropic",
1225 BaseURL: "https://gateway.example/anthropic",
1226 Models: []string{"custom-model"},
1227 WebSearch: &enabled,
1228 }}
1229 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1230 t.Fatalf("SaveTo: %v", err)
1231 }
1232
1233 if err := NewApp().SaveProvider(ProviderView{
1234 Name: "custom-anthropic",
1235 Kind: "anthropic",
1236 BaseURL: "https://gateway.example/anthropic",
1237 Models: []string{"custom-model"},
1238 Default: "custom-model",
1239 ContextWindow: 200_000,
1240 WebSearch: false, // The hidden Settings control must not overwrite advanced TOML.
1241 }); err != nil {
1242 t.Fatalf("SaveProvider: %v", err)
1243 }
1244
1245 gotCfg := config.LoadForEdit(config.UserConfigPath())
1246 got, ok := gotCfg.Provider("custom-anthropic")
1247 if !ok || got.WebSearch == nil || !*got.WebSearch || !config.EffectiveWebSearch(got) {
1248 t.Fatalf("saved provider = %+v, found=%v; want preserved advanced web_search=true", got, ok)
1249 }
1250 }
1251
1252 func TestSaveProviderDoesNotCarryOfficialWebSearchToCustomEndpoint(t *testing.T) {
1253 isolateDesktopUserDirs(t)
1254
1255 enabled := true
1256 cfg := config.LoadForEdit(config.UserConfigPath())
1257 cfg.Providers = []config.ProviderEntry{{
1258 Name: "deepseek-customized",
1259 Kind: "anthropic",
1260 BaseURL: "https://api.deepseek.com/anthropic",
1261 Models: []string{"deepseek-v4-flash"},
1262 WebSearch: &enabled,
1263 }}
1264 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1265 t.Fatalf("SaveTo: %v", err)
1266 }
1267
1268 if err := NewApp().SaveProvider(ProviderView{
1269 Name: "deepseek-customized",
1270 Kind: "anthropic",
1271 BaseURL: "https://gateway.example/anthropic",
1272 Models: []string{"deepseek-v4-flash"},
1273 Default: "deepseek-v4-flash",
1274 WebSearch: false,
1275 }); err != nil {
1276 t.Fatalf("SaveProvider: %v", err)
1277 }
1278
1279 gotCfg := config.LoadForEdit(config.UserConfigPath())
1280 got, ok := gotCfg.Provider("deepseek-customized")
1281 if !ok || got.WebSearch != nil || config.EffectiveWebSearch(got) {
1282 t.Fatalf("saved provider = %+v, found=%v; want official web search cleared after endpoint change", got, ok)
1283 }
1284 }
1285
1286 func TestUpgradeDeepSeekProviderAccessPreservesCustomizedFields(t *testing.T) {
1287 isolateDesktopUserDirs(t)
1288 path := config.UserConfigPath()
1289 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1290 t.Fatal(err)
1291 }
1292 raw := `default_model = "deepseek-flash/deepseek-v4-flash"
1293
1294 [desktop]
1295 provider_access = ["deepseek"]
1296
1297 [[providers]]
1298 name = "deepseek-flash"
1299 kind = "openai"
1300 base_url = "https://api.deepseek.com"
1301 model = "deepseek-v4-flash"
1302 api_key_env = "DEEPSEEK_API_KEY"
1303 vision = true
1304 chat_url = "https://api.deepseek.com/anthropic/v1/messages"
1305 models_url = "https://api.deepseek.com/models"
1306 headers = { X-Trace = "keep" }
1307 extra_body = { route = "keep" }
1308 auth_header = true
1309 thinking = "enabled"
1310 web_search = true
1311 no_proxy = true
1312 cache_ttl_minutes = 17
1313 context_window = 900000
1314 max_output_tokens = 111111
1315 supported_efforts = ["disabled", "low", "high"]
1316 default_effort = "low"
1317 price = { cache_hit = 0.1, input = 1.25, output = 2.25, currency = "T" }
1318 future_capability = "keep"
1319
1320 [[providers]]
1321 name = "deepseek-pro"
1322 kind = "openai"
1323 base_url = "https://api.deepseek.com"
1324 model = "deepseek-v4-pro"
1325 api_key_env = "DEEPSEEK_API_KEY"
1326 chat_url = "https://api.deepseek.com/anthropic/v1/messages"
1327 models_url = "https://api.deepseek.com/models"
1328 headers = { X-Trace = "keep" }
1329 extra_body = { route = "keep" }
1330 auth_header = true
1331 thinking = "enabled"
1332 web_search = true
1333 no_proxy = true
1334 cache_ttl_minutes = 17
1335 context_window = 800000
1336 max_output_tokens = 222222
1337 reasoning_protocol = "none"
1338 supported_efforts = ["disabled", "high", "max"]
1339 default_effort = "max"
1340 price = { cache_hit = 0.2, input = 3.75, output = 6.75, currency = "T" }
1341 `
1342 if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
1343 t.Fatal(err)
1344 }
1345
1346 if _, err := NewApp().UpgradeDeepSeekProviderAccess("deepseek"); err != nil {
1347 t.Fatalf("UpgradeDeepSeekProviderAccess: %v", err)
1348 }
1349 updated, err := os.ReadFile(path)
1350 if err != nil {
1351 t.Fatal(err)
1352 }
1353 text := string(updated)
1354 if strings.Count(text, `kind = "anthropic"`) != 2 ||
1355 strings.Count(text, `base_url = "https://api.deepseek.com/anthropic"`) != 2 {
1356 t.Fatalf("provider family was not upgraded:\n%s", text)
1357 }
1358 for _, preserved := range []string{`vision = true`, `headers = { X-Trace = "keep" }`, `extra_body = { route = "keep" }`, `future_capability = "keep"`, `reasoning_protocol = "none"`} {
1359 if !strings.Contains(text, preserved) {
1360 t.Errorf("upgrade dropped %q:\n%s", preserved, text)
1361 }
1362 }
1363
1364 cfg, err := config.LoadForRootReadOnly(t.TempDir())
1365 if err != nil {
1366 t.Fatalf("load upgraded config: %v", err)
1367 }
1368 if got := cfg.Desktop.ProviderAccess; len(got) != 1 || got[0] != "deepseek" {
1369 t.Fatalf("provider_access = %v, want one canonical DeepSeek entry", got)
1370 }
1371 canonical, ok := cfg.Provider("deepseek")
1372 if !ok {
1373 t.Fatal("effective canonical DeepSeek provider missing after upgrade")
1374 }
1375 if canonical.ChatURL != "https://api.deepseek.com/anthropic/v1/messages" ||
1376 canonical.ModelsURL != "https://api.deepseek.com/models" ||
1377 canonical.Headers["X-Trace"] != "keep" || canonical.ExtraBody["route"] != "keep" ||
1378 !canonical.AuthHeader || !canonical.NoProxy || canonical.CacheTTLMinutes != 17 {
1379 t.Fatalf("canonical transport fields were not preserved: %+v", canonical)
1380 }
1381 flash, ok := cfg.ResolveModel("deepseek/deepseek-v4-flash")
1382 if !ok {
1383 t.Fatal("canonical DeepSeek Flash model did not resolve")
1384 }
1385 if flash.ContextWindow != 900000 || flash.MaxOutputTokens != 111111 || flash.DefaultEffort != "low" ||
1386 flash.Price == nil || flash.Price.Output != 2.25 {
1387 t.Fatalf("Flash model fields were not preserved: %+v", flash)
1388 }
1389 if !config.EffectiveVision(flash) {
1390 t.Fatal("V4 Flash is natively multimodal on the official DeepSeek endpoint")
1391 }
1392 flashOverride := canonical.ModelOverrides["deepseek-v4-flash"]
1393 if flashOverride.Vision == nil || !*flashOverride.Vision {
1394 t.Fatalf("Flash vision metadata was dropped instead of being safely ignored: %+v", flashOverride)
1395 }
1396 pro, ok := cfg.ResolveModel("deepseek/deepseek-v4-pro")
1397 if !ok {
1398 t.Fatal("canonical DeepSeek Pro model did not resolve")
1399 }
1400 if pro.ContextWindow != 800000 || pro.MaxOutputTokens != 222222 || pro.ReasoningProtocol != "none" ||
1401 pro.DefaultEffort != "max" || pro.Price == nil || pro.Price.Output != 6.75 {
1402 t.Fatalf("Pro model fields were not preserved: %+v", pro)
1403 }
1404 }
1405
1406 func TestUpgradeDeepSeekProviderAccessDoesNotWaitForRuntimeRebuild(t *testing.T) {
1407 isolateDesktopUserDirs(t)
1408 path := config.UserConfigPath()
1409 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1410 t.Fatal(err)
1411 }
1412 raw := `[[providers]]
1413 name = "deepseek-flash"
1414 kind = "openai"
1415 base_url = "https://api.deepseek.com"
1416 model = "deepseek-v4-flash"
1417 api_key_env = "DEEPSEEK_API_KEY"
1418 `
1419 if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
1420 t.Fatal(err)
1421 }
1422
1423 app := NewApp()
1424 app.runtimeRebuildMu.Lock()
1425 rebuildLocked := true
1426 defer func() {
1427 if rebuildLocked {
1428 app.runtimeRebuildMu.Unlock()
1429 }
1430 }()
1431 done := make(chan error, 1)
1432 go func() {
1433 _, err := app.UpgradeDeepSeekProviderAccess("deepseek")
1434 done <- err
1435 }()
1436 select {
1437 case err := <-done:
1438 if err != nil {
1439 t.Fatalf("UpgradeDeepSeekProviderAccess: %v", err)
1440 }
1441 case <-time.After(5 * time.Second):
1442 t.Fatal("protocol save waited for the runtime mutation lock")
1443 }
1444 app.runtimeRebuildMu.Unlock()
1445 rebuildLocked = false
1446 after, err := os.ReadFile(path)
1447 if err != nil {
1448 t.Fatal(err)
1449 }
1450 if !strings.Contains(string(after), `kind = "anthropic"`) {
1451 t.Fatalf("protocol was not upgraded after acquiring the runtime mutation lock:\n%s", after)
1452 }
1453 }
1454
1455 func TestUpgradeDeepSeekProviderAccessPreservesEveryVisibleRuntime(t *testing.T) {
1456 isolateDesktopUserDirs(t)
1457 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1458 path := config.UserConfigPath()
1459 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1460 t.Fatal(err)
1461 }
1462 raw := `default_model = "deepseek-flash/deepseek-v4-flash"
1463
1464 [desktop]
1465 provider_access = ["deepseek"]
1466
1467 [[providers]]
1468 name = "deepseek-flash"
1469 kind = "openai"
1470 base_url = "https://api.deepseek.com"
1471 model = "deepseek-v4-flash"
1472 api_key_env = "DEEPSEEK_API_KEY"
1473 `
1474 if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
1475 t.Fatal(err)
1476 }
1477
1478 workspace := t.TempDir()
1479 sessionDir := config.SessionDir()
1480 if err := os.MkdirAll(sessionDir, 0o700); err != nil {
1481 t.Fatal(err)
1482 }
1483 newOldController := func(id string) *blockingSnapshotCtrl {
1484 session := agent.NewSession("old system prompt")
1485 session.Add(provider.Message{Role: provider.RoleUser, Content: "history " + id})
1486 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
1487 ctrl := control.New(control.Options{
1488 Executor: exec, SessionDir: sessionDir,
1489 SessionPath: filepath.Join(sessionDir, id+".jsonl"), Label: id, Sink: event.Discard,
1490 })
1491 wrapped := newBlockingSnapshotCtrl(ctrl)
1492 close(wrapped.releaseSnapshot)
1493 return wrapped
1494 }
1495 oldA := newOldController("tab-a")
1496 oldB := newOldController("tab-b")
1497 app := NewApp()
1498 app.ctx = context.Background()
1499 app.readyHook = func() {}
1500 tabA := &WorkspaceTab{
1501 ID: "tab-a", Scope: "global", WorkspaceRoot: workspace, Ready: true,
1502 Ctrl: oldA, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "tab-a", app: app},
1503 disabledMCP: map[string]ServerView{},
1504 }
1505 tabB := &WorkspaceTab{
1506 ID: "tab-b", Scope: "global", WorkspaceRoot: workspace, Ready: true,
1507 Ctrl: oldB, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "tab-b", app: app},
1508 disabledMCP: map[string]ServerView{},
1509 }
1510 app.tabs = map[string]*WorkspaceTab{tabA.ID: tabA, tabB.ID: tabB}
1511 app.tabOrder = []string{tabA.ID, tabB.ID}
1512 app.activeTabID = tabA.ID
1513 t.Cleanup(func() {
1514 for _, tab := range []*WorkspaceTab{tabA, tabB} {
1515 if tab.Ctrl != nil {
1516 tab.Ctrl.Close()
1517 }
1518 tab.releaseSessionLease()
1519 }
1520 })
1521
1522 if _, err := app.UpgradeDeepSeekProviderAccess("deepseek"); err != nil {
1523 t.Fatalf("UpgradeDeepSeekProviderAccess: %v", err)
1524 }
1525 if tabA.Ctrl != oldA || tabB.Ctrl != oldB {
1526 t.Fatal("saving protocol replaced a visible runtime")
1527 }
1528 if oldA.closeCount.Load() != 0 || oldB.closeCount.Load() != 0 {
1529 t.Fatal("saving protocol closed an existing controller")
1530 }
1531 for _, tab := range []*WorkspaceTab{tabA, tabB} {
1532 history := tab.Ctrl.History()
1533 if len(history) < 2 || !strings.HasPrefix(history[1].Content, "history ") {
1534 t.Fatalf("rebuilt tab %q lost history: %+v", tab.ID, history)
1535 }
1536 }
1537 }
1538
1539 func TestUpgradeDeepSeekProviderAccessSavesIndependentlyOfWorkspaceBuild(t *testing.T) {
1540 isolateDesktopUserDirs(t)
1541 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1542 path := config.UserConfigPath()
1543 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1544 t.Fatal(err)
1545 }
1546 // The explicit Settings action runs after the one-time startup migration.
1547 // The running app has completed the startup migration before a manual switch.
1548 raw := `config_version = 9
1549 default_model = "deepseek-flash/deepseek-v4-flash"
1550
1551 [desktop]
1552 provider_access = ["deepseek"]
1553
1554 [[providers]]
1555 name = "deepseek-flash"
1556 kind = "openai"
1557 base_url = "https://api.deepseek.com"
1558 model = "deepseek-v4-flash"
1559 api_key_env = "DEEPSEEK_API_KEY"
1560 `
1561 if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
1562 t.Fatal(err)
1563 }
1564
1565 brokenRoot := t.TempDir()
1566 if err := os.WriteFile(filepath.Join(brokenRoot, "reasonix.toml"), []byte(`[agent]
1567 system_prompt_file = "/outside-workspace/system.md"
1568 `), 0o600); err != nil {
1569 t.Fatal(err)
1570 }
1571 workingRoot := t.TempDir()
1572 sessionDir := config.SessionDir()
1573 if err := os.MkdirAll(sessionDir, 0o700); err != nil {
1574 t.Fatal(err)
1575 }
1576 newOldController := func(id string) *blockingSnapshotCtrl {
1577 session := agent.NewSession("old system prompt")
1578 session.Add(provider.Message{Role: provider.RoleUser, Content: "history " + id})
1579 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
1580 ctrl := control.New(control.Options{
1581 Executor: exec, SessionDir: sessionDir,
1582 SessionPath: filepath.Join(sessionDir, id+".jsonl"), Label: id, Sink: event.Discard,
1583 })
1584 wrapped := newBlockingSnapshotCtrl(ctrl)
1585 close(wrapped.releaseSnapshot)
1586 return wrapped
1587 }
1588 oldBroken := newOldController("upgrade-broken")
1589 oldWorking := newOldController("upgrade-working")
1590 app := NewApp()
1591 app.ctx = context.Background()
1592 app.readyHook = func() {}
1593 broken := &WorkspaceTab{
1594 ID: "a-broken", Scope: "project", WorkspaceRoot: brokenRoot, Ready: true,
1595 Ctrl: oldBroken, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "a-broken", app: app},
1596 disabledMCP: map[string]ServerView{},
1597 }
1598 working := &WorkspaceTab{
1599 ID: "b-working", Scope: "project", WorkspaceRoot: workingRoot, Ready: true,
1600 Ctrl: oldWorking, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "b-working", app: app},
1601 disabledMCP: map[string]ServerView{},
1602 }
1603 app.tabs = map[string]*WorkspaceTab{broken.ID: broken, working.ID: working}
1604 app.tabOrder = []string{broken.ID, working.ID}
1605 app.activeTabID = broken.ID
1606 t.Cleanup(func() {
1607 for _, tab := range []*WorkspaceTab{broken, working} {
1608 if tab.Ctrl != nil {
1609 tab.Ctrl.Close()
1610 }
1611 tab.releaseSessionLease()
1612 }
1613 })
1614
1615 warning, err := app.UpgradeDeepSeekProviderAccess("deepseek")
1616 if err != nil {
1617 t.Fatalf("UpgradeDeepSeekProviderAccess save: %v", err)
1618 }
1619 if warning != "" {
1620 t.Fatalf("UpgradeDeepSeekProviderAccess warning = %q, want no lease warning", warning)
1621 }
1622 updated, readErr := os.ReadFile(path)
1623 if readErr != nil {
1624 t.Fatal(readErr)
1625 }
1626 if !strings.Contains(string(updated), `kind = "anthropic"`) {
1627 t.Fatalf("protocol was not persisted before the runtime error:\n%s", updated)
1628 }
1629 if broken.Ctrl != oldBroken || oldBroken.closeCount.Load() != 0 {
1630 t.Fatalf("failed tab changed controller: ctrl=%T closes=%d", broken.Ctrl, oldBroken.closeCount.Load())
1631 }
1632 if working.Ctrl != oldWorking || oldWorking.closeCount.Load() != 0 {
1633 t.Fatal("saving protocol replaced the sibling runtime")
1634 }
1635 }
1636
1637 func TestUpgradeDeepSeekProviderAccessPreservesLeasedTabAndSibling(t *testing.T) {
1638 isolateDesktopUserDirs(t)
1639 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1640 path := config.UserConfigPath()
1641 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1642 t.Fatal(err)
1643 }
1644 raw := `default_model = "deepseek-flash/deepseek-v4-flash"
1645
1646 [desktop]
1647 provider_access = ["deepseek"]
1648
1649 [[providers]]
1650 name = "deepseek-flash"
1651 kind = "openai"
1652 base_url = "https://api.deepseek.com"
1653 model = "deepseek-v4-flash"
1654 api_key_env = "DEEPSEEK_API_KEY"
1655 `
1656 if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
1657 t.Fatal(err)
1658 }
1659
1660 workspace := t.TempDir()
1661 sessionDir := config.SessionDir()
1662 if err := os.MkdirAll(sessionDir, 0o700); err != nil {
1663 t.Fatal(err)
1664 }
1665 leasedPath := filepath.Join(sessionDir, "upgrade-leased.jsonl")
1666 workingPath := filepath.Join(sessionDir, "upgrade-working.jsonl")
1667 externalLease, err := agent.TryAcquireSessionLease(leasedPath)
1668 if err != nil {
1669 t.Fatalf("TryAcquireSessionLease: %v", err)
1670 }
1671 t.Cleanup(externalLease.Release)
1672 newOldController := func(id, sessionPath string) *blockingSnapshotCtrl {
1673 session := agent.NewSession("old system prompt")
1674 session.Add(provider.Message{Role: provider.RoleUser, Content: "history " + id})
1675 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
1676 ctrl := control.New(control.Options{
1677 Executor: exec, SessionDir: sessionDir, SessionPath: sessionPath, Label: id, Sink: event.Discard,
1678 })
1679 wrapped := newBlockingSnapshotCtrl(ctrl)
1680 close(wrapped.releaseSnapshot)
1681 return wrapped
1682 }
1683 oldLeased := newOldController("upgrade-leased", leasedPath)
1684 oldWorking := newOldController("upgrade-working", workingPath)
1685 app := NewApp()
1686 app.ctx = context.Background()
1687 app.readyHook = func() {}
1688 leased := &WorkspaceTab{
1689 ID: "a-leased", Scope: "global", WorkspaceRoot: workspace, SessionPath: leasedPath, Ready: true,
1690 Ctrl: oldLeased, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "a-leased", app: app},
1691 disabledMCP: map[string]ServerView{},
1692 }
1693 working := &WorkspaceTab{
1694 ID: "b-working", Scope: "global", WorkspaceRoot: workspace, SessionPath: workingPath, Ready: true,
1695 Ctrl: oldWorking, model: "deepseek/deepseek-v4-flash", sink: &tabEventSink{tabID: "b-working", app: app},
1696 disabledMCP: map[string]ServerView{},
1697 }
1698 app.tabs = map[string]*WorkspaceTab{leased.ID: leased, working.ID: working}
1699 app.tabOrder = []string{leased.ID, working.ID}
1700 app.activeTabID = leased.ID
1701 t.Cleanup(func() {
1702 for _, tab := range []*WorkspaceTab{leased, working} {
1703 if tab.Ctrl != nil {
1704 tab.Ctrl.Close()
1705 }
1706 tab.releaseSessionLease()
1707 }
1708 })
1709
1710 warning, err := app.UpgradeDeepSeekProviderAccess("deepseek")
1711 if err != nil {
1712 t.Fatalf("UpgradeDeepSeekProviderAccess: %v", err)
1713 }
1714 if warning != "" {
1715 t.Fatalf("protocol save acquired runtime lease: %q", warning)
1716 }
1717 if app.deferredRebuildPending(leased.ID) {
1718 t.Fatal("saving protocol scheduled an immediate rebuild")
1719 }
1720 if app.deferredRebuildPending(working.ID) {
1721 t.Fatal("working sibling unexpectedly received a deferred rebuild")
1722 }
1723 if leased.Ctrl != oldLeased || oldLeased.closeCount.Load() != 0 {
1724 t.Fatalf("leased tab changed controller: ctrl=%T closes=%d", leased.Ctrl, oldLeased.closeCount.Load())
1725 }
1726 if working.Ctrl != oldWorking || oldWorking.closeCount.Load() != 0 {
1727 t.Fatal("saving protocol replaced the sibling runtime")
1728 }
1729 }
1730
1731 func TestUpgradeDeepSeekProviderAccessSavesWithDetachedRuntime(t *testing.T) {
1732 isolateDesktopUserDirs(t)
1733 path := config.UserConfigPath()
1734 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1735 t.Fatal(err)
1736 }
1737 raw := `[[providers]]
1738 name = "deepseek-flash"
1739 kind = "openai"
1740 base_url = "https://api.deepseek.com"
1741 model = "deepseek-v4-flash"
1742 api_key_env = "DEEPSEEK_API_KEY"
1743 `
1744 if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
1745 t.Fatal(err)
1746 }
1747
1748 app := NewApp()
1749 detachedCtrl := control.New(control.Options{Label: "detached", Sink: event.Discard})
1750 app.detachedSessions = map[string]*WorkspaceTab{
1751 "detached": {ID: "detached", Scope: "global", Ready: true, Ctrl: detachedCtrl},
1752 }
1753 t.Cleanup(detachedCtrl.Close)
1754
1755 _, err := app.UpgradeDeepSeekProviderAccess("deepseek")
1756 if err != nil || app.detachedSessions["detached"].Ctrl != detachedCtrl {
1757 t.Fatalf("protocol save must preserve detached runtime: %v", err)
1758 }
1759 after, readErr := os.ReadFile(path)
1760 if readErr != nil {
1761 t.Fatal(readErr)
1762 }
1763 if !strings.Contains(string(after), `kind = "anthropic"`) {
1764 t.Fatalf("protocol was not saved with detached runtime:\n%s", after)
1765 }
1766 }
1767
1768 func TestProviderModelOverrideViewPreservesMaxOutputTokens(t *testing.T) {
1769 input := map[string]config.ProviderModelOverride{
1770 "limited": {ContextWindow: 64_000, MaxOutputTokens: 8_192},
1771 "omitted": {MaxOutputTokens: -1},
1772 }
1773 views := providerModelOverridesForView(input, []string{"limited", "omitted"})
1774 if len(views) != 2 || views[0].MaxOutputTokens != 8_192 || views[1].MaxOutputTokens != -1 {
1775 t.Fatalf("model override views = %+v, want positive and negative output-token semantics preserved", views)
1776 }
1777 roundTrip := providerModelOverridesForSave(views, []string{"limited", "omitted"})
1778 if got := roundTrip["limited"]; got.ContextWindow != 64_000 || got.MaxOutputTokens != 8_192 {
1779 t.Fatalf("limited override = %+v, want context and output limits preserved", got)
1780 }
1781 if got := roundTrip["omitted"]; got.MaxOutputTokens != -1 {
1782 t.Fatalf("omitted override = %+v, want negative wire-omission marker preserved", got)
1783 }
1784 }
1785
1786 func TestDeepSeekProtocolUpgradeSourceAvailableWithLegacyGlobalAndProjectConfig(t *testing.T) {
1787 isolateDesktopUserDirs(t)
1788 legacyPath := config.LegacyUserConfigPath()
1789 if legacyPath == "" {
1790 t.Skip("platform has no distinct legacy user-config path")
1791 }
1792 if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil {
1793 t.Fatal(err)
1794 }
1795 if err := os.WriteFile(legacyPath, []byte(`[[providers]]
1796 name = "deepseek-flash"
1797 kind = "openai"
1798 base_url = "https://api.deepseek.com"
1799 model = "deepseek-v4-flash"
1800 api_key_env = "DEEPSEEK_API_KEY"
1801 `), 0o600); err != nil {
1802 t.Fatal(err)
1803 }
1804 project := t.TempDir()
1805 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("# project config\n"), 0o600); err != nil {
1806 t.Fatal(err)
1807 }
1808 if !config.CanUpgradeDeepSeekProviderProtocolUserConfig("deepseek") {
1809 t.Fatal("project config must not hide an available legacy global upgrade source")
1810 }
1811 }
1812
1813 func TestOfficialMimoAPITemplateRemoved(t *testing.T) {
1814 if entries, keyEnv, err := officialProviderTemplate("mimo-api", "en"); err == nil {
1815 t.Fatalf("officialProviderTemplate(mimo-api) = entries=%v key=%q nil error, want unknown template", entries, keyEnv)
1816 }
1817 }
1818
1819 func TestOfficialDeepSeekTemplateUsesRegionalPricing(t *testing.T) {
1820 // Language no longer selects list-price tables; templates freeze the default
1821 // USD official rates. Display currency is independent (billing.display_currency).
1822 for _, language := range []string{"en", "zh"} {
1823 entries, keyEnv, err := officialProviderTemplate("deepseek", language)
1824 if err != nil {
1825 t.Fatalf("officialProviderTemplate(%s): %v", language, err)
1826 }
1827 if keyEnv != "DEEPSEEK_API_KEY" || len(entries) != 1 {
1828 t.Fatalf("template = %v/%q, want one DEEPSEEK_API_KEY entry", entries, keyEnv)
1829 }
1830 got := entries[0]
1831 if got.Kind != "openai" || got.BaseURL != "https://api.deepseek.com" || !config.EffectiveIndependentWebSearch(&got) || got.Thinking != "enabled" {
1832 t.Fatalf("%s DeepSeek template = kind:%q base_url:%q web_search:%t thinking:%q, want Chat Completions with independent web search", language, got.Kind, got.BaseURL, config.EffectiveIndependentWebSearch(&got), got.Thinking)
1833 }
1834 if price := got.Prices["deepseek-v4-flash"]; price == nil || price.Currency != "$" || price.Output != 1.2 {
1835 t.Fatalf("%s deepseek-v4-flash price = %+v, want frozen USD table", language, price)
1836 }
1837 if price := got.Prices["deepseek-v4-pro"]; price == nil || price.Currency != "$" || price.Output != 3.96 {
1838 t.Fatalf("%s deepseek-v4-pro price = %+v, want frozen USD table", language, price)
1839 }
1840 if price := got.Prices[openai.OfficialDeepSeekVisionModel]; price == nil || price.Currency != "$" || price.Output != 1.2 {
1841 t.Fatalf("%s vision SKU price = %+v, want Flash USD table", language, price)
1842 }
1843 }
1844 }
1845
1846 func TestSetAgentParamsIgnoresDeprecatedStepLimits(t *testing.T) {
1847 isolateDesktopUserDirs(t)
1848
1849 app := NewApp()
1850 if err := app.SetAgentParams(0.35, 37, 9, "custom system"); err != nil {
1851 t.Fatalf("SetAgentParams: %v", err)
1852 }
1853
1854 view := app.Settings()
1855 if view.Agent.MaxSteps != 0 || view.Agent.PlannerMaxSteps != 0 {
1856 t.Fatalf("Settings().Agent = %+v, want deprecated step limits normalized to zero", view.Agent)
1857 }
1858 if view.Agent.Temperature != 0.35 || view.Agent.SystemPrompt != "custom system" {
1859 t.Fatalf("Settings().Agent did not preserve other agent params: %+v", view.Agent)
1860 }
1861
1862 cfg := config.LoadForEdit(config.UserConfigPath())
1863 if cfg.Agent.MaxSteps != 0 || cfg.Agent.PlannerMaxSteps != 0 {
1864 t.Fatalf("saved config agent steps = max:%d planner:%d, want automatic 0/0", cfg.Agent.MaxSteps, cfg.Agent.PlannerMaxSteps)
1865 }
1866 if cfg.Agent.Temperature != 0.35 || cfg.Agent.SystemPrompt != "custom system" {
1867 t.Fatalf("saved config did not preserve other agent params: %+v", cfg.Agent)
1868 }
1869 }
1870
1871 func TestSetReasoningLanguagePersistsToUserConfig(t *testing.T) {
1872 isolateDesktopUserDirs(t)
1873
1874 app := NewApp()
1875 if err := app.SetReasoningLanguage("zh"); err != nil {
1876 t.Fatalf("SetReasoningLanguage: %v", err)
1877 }
1878
1879 view := app.Settings()
1880 if view.Agent.ReasoningLanguage != "zh" {
1881 t.Fatalf("Settings().Agent.ReasoningLanguage = %q, want zh", view.Agent.ReasoningLanguage)
1882 }
1883
1884 cfg := config.LoadForEdit(config.UserConfigPath())
1885 if cfg.Agent.ReasoningLanguage != "zh" || cfg.ReasoningLanguage() != "zh" {
1886 t.Fatalf("saved reasoning language = %q/%q, want zh", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())
1887 }
1888 }
1889
1890 func TestSetCompactRatioPersistsToUserConfig(t *testing.T) {
1891 isolateDesktopUserDirs(t)
1892
1893 app := NewApp()
1894 defaultView := app.Settings()
1895 if defaultView.Agent.CompactRatio != 0.80 || defaultView.Agent.EffectiveCompactRatio != 0.80 {
1896 t.Fatalf("default compact ratios = %v/%v, want 0.80/0.80", defaultView.Agent.CompactRatio, defaultView.Agent.EffectiveCompactRatio)
1897 }
1898 if err := app.SetCompactRatio(0.7); err != nil {
1899 t.Fatalf("SetCompactRatio: %v", err)
1900 }
1901
1902 view := app.Settings()
1903 if view.Agent.CompactRatio != 0.7 {
1904 t.Fatalf("Settings().Agent.CompactRatio = %v, want 0.7", view.Agent.CompactRatio)
1905 }
1906
1907 cfg := config.LoadForEdit(config.UserConfigPath())
1908 if cfg.Agent.CompactRatio != 0.7 {
1909 t.Fatalf("saved compact ratio = %v, want 0.7", cfg.Agent.CompactRatio)
1910 }
1911 // Deprecated multi-threshold fields stay cleared / unused.
1912 if cfg.Agent.ToolResultSnipRatio != 0 || cfg.Agent.CompactForceRatio != 0 {
1913 t.Fatalf("setting compact ratio revived deprecated thresholds: %+v", cfg.Agent)
1914 }
1915 if err := app.SetCompactRatio(0.3); err != nil {
1916 t.Fatalf("SetCompactRatio lower bound: %v", err)
1917 }
1918 view = app.Settings()
1919 if view.Agent.CompactRatio != 0.3 {
1920 t.Fatalf("Settings().Agent.CompactRatio = %v, want 0.3", view.Agent.CompactRatio)
1921 }
1922 cfg = config.LoadForEdit(config.UserConfigPath())
1923 if cfg.Agent.CompactRatio != 0.3 {
1924 t.Fatalf("saved compact ratio = %v, want 0.3", cfg.Agent.CompactRatio)
1925 }
1926
1927 if err := app.SetCompactRatio(0.9); err == nil {
1928 t.Fatal("SetCompactRatio should reject values outside the Desktop safety range")
1929 }
1930 cfg = config.LoadForEdit(config.UserConfigPath())
1931 if cfg.Agent.CompactRatio != 0.3 {
1932 t.Fatalf("rejected update changed saved compact ratio to %v", cfg.Agent.CompactRatio)
1933 }
1934 }
1935
1936 func TestSetCompactRatioRejectsActiveWorkBeforeSaving(t *testing.T) {
1937 isolateDesktopUserDirs(t)
1938
1939 app := NewApp()
1940 app.setTestCtrl(newBackgroundJobController(t, "compact-ratio-job"), "")
1941 err := app.SetCompactRatio(0.7)
1942 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
1943 t.Fatalf("SetCompactRatio with background job error = %v, want active-work guard", err)
1944 }
1945 if got := config.LoadForEdit(config.UserConfigPath()).Agent.CompactRatio; got != 0.80 {
1946 t.Fatalf("compact ratio changed after rejected update: %v", got)
1947 }
1948 }
1949
1950 func TestSetDesktopLanguagePersistsResponseLanguageAndUpdatesLiveTabs(t *testing.T) {
1951 isolateDesktopUserDirs(t)
1952 projectRoot := t.TempDir()
1953 if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte("language = \"zh\"\n"), 0o644); err != nil {
1954 t.Fatal(err)
1955 }
1956
1957 app := NewApp()
1958 userCtrl := control.New(control.Options{})
1959 projectCtrl := control.New(control.Options{})
1960 app.tabs = map[string]*WorkspaceTab{
1961 "user": {
1962 ID: "user",
1963 Scope: "global",
1964 Ctrl: userCtrl,
1965 Ready: true,
1966 disabledMCP: map[string]ServerView{},
1967 },
1968 "project": {
1969 ID: "project",
1970 Scope: "project",
1971 WorkspaceRoot: projectRoot,
1972 Ctrl: projectCtrl,
1973 Ready: true,
1974 disabledMCP: map[string]ServerView{},
1975 },
1976 }
1977 app.activeTabID = "user"
1978
1979 if err := app.SetDesktopLanguage("en"); err != nil {
1980 t.Fatalf("SetDesktopLanguage: %v", err)
1981 }
1982 cfg := config.LoadForEdit(config.UserConfigPath())
1983 if cfg.DesktopLanguage() != "en" || cfg.Language != "en" {
1984 t.Fatalf("saved language prefs = desktop:%q response:%q, want en/en", cfg.DesktopLanguage(), cfg.Language)
1985 }
1986 got := userCtrl.Compose("解释这个函数")
1987 if !strings.Contains(got, "<response-language>") || !strings.Contains(got, "use English") {
1988 t.Fatalf("live controller Compose = %q, want English response language", got)
1989 }
1990 projectComposed := projectCtrl.Compose("explain this function")
1991 if !strings.Contains(projectComposed, "use Simplified Chinese") {
1992 t.Fatalf("project controller Compose = %q, want project zh response language", projectComposed)
1993 }
1994 }
1995
1996 func TestSetDesktopCurrencyPersistsDisplayWithoutRewritingOfficialPricing(t *testing.T) {
1997 isolateDesktopUserDirs(t)
1998
1999 app := NewApp()
2000 if err := app.SetDesktopCurrency("CNY"); err != nil {
2001 t.Fatalf("SetDesktopCurrency: %v", err)
2002 }
2003
2004 view := app.Settings()
2005 if view.DesktopCurrency != "CNY" {
2006 t.Fatalf("Settings().DesktopCurrency = %q, want CNY", view.DesktopCurrency)
2007 }
2008 cfg := config.LoadForEdit(config.UserConfigPath())
2009 if got := cfg.DisplayCurrencyPref(); got != "CNY" {
2010 t.Fatalf("display pref = %q, want CNY", got)
2011 }
2012 flash, ok := cfg.Provider("deepseek-flash")
2013 // Display currency must not rewrite frozen list prices (default USD table).
2014 if !ok || flash.Price == nil || flash.Price.Output != 1.2 || flash.Price.Currency != "$" {
2015 t.Fatalf("saved DeepSeek flash price = %+v, want frozen USD official price", flash)
2016 }
2017 }
2018
2019 func TestSetReasoningLanguageUpdatesLiveTabControllers(t *testing.T) {
2020 isolateDesktopUserDirs(t)
2021 projectRoot := t.TempDir()
2022 if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte("[agent]\nreasoning_language = \"en\"\n"), 0o644); err != nil {
2023 t.Fatal(err)
2024 }
2025
2026 app := NewApp()
2027 userCtrl := control.New(control.Options{ReasoningLanguage: "auto"})
2028 projectCtrl := control.New(control.Options{ReasoningLanguage: "auto"})
2029 app.tabs = map[string]*WorkspaceTab{
2030 "user": {
2031 ID: "user",
2032 Scope: "global",
2033 Ctrl: userCtrl,
2034 Ready: true,
2035 disabledMCP: map[string]ServerView{},
2036 },
2037 "project": {
2038 ID: "project",
2039 Scope: "project",
2040 WorkspaceRoot: projectRoot,
2041 Ctrl: projectCtrl,
2042 Ready: true,
2043 disabledMCP: map[string]ServerView{},
2044 },
2045 }
2046 app.activeTabID = "user"
2047
2048 if err := app.SetReasoningLanguage("zh"); err != nil {
2049 t.Fatalf("SetReasoningLanguage: %v", err)
2050 }
2051
2052 userComposed := userCtrl.Compose("hi")
2053 if !strings.Contains(userComposed, "简体中文") {
2054 t.Fatalf("user-level tab Compose = %q, want zh reasoning language", userComposed)
2055 }
2056 projectComposed := projectCtrl.Compose("hi")
2057 if !strings.Contains(projectComposed, "use English") {
2058 t.Fatalf("project override tab Compose = %q, want en reasoning language", projectComposed)
2059 }
2060 }
2061
2062 func TestSetAutoPlanCompatibilityCannotReenableRetiredFeature(t *testing.T) {
2063 isolateDesktopUserDirs(t)
2064
2065 app := NewApp()
2066 if err := app.SetAutoPlan("off"); err != nil {
2067 t.Fatalf("SetAutoPlan(off): %v", err)
2068 }
2069 if err := app.SetAutoPlan("on"); err == nil || !strings.Contains(err.Error(), "retired") {
2070 t.Fatalf("SetAutoPlan(on) error = %v, want retired error", err)
2071 }
2072 got := config.LoadForEdit(config.UserConfigPath())
2073 if got.Agent.AutoPlan != "off" || got.Agent.AutoPlanClassifier != "" {
2074 t.Fatalf("retired auto-plan state = (%q, %q), want off/empty", got.Agent.AutoPlan, got.Agent.AutoPlanClassifier)
2075 }
2076 }
2077
2078 func TestSetReasoningLanguageRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {
2079 isolateDesktopUserDirs(t)
2080
2081 app := NewApp()
2082 app.setTestCtrl(newBackgroundJobController(t, "reasoning-language-job"), "")
2083
2084 err := app.SetReasoningLanguage("zh")
2085 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
2086 t.Fatalf("SetReasoningLanguage with background job error = %v, want active-work guard", err)
2087 }
2088 cfg := config.LoadForEdit(config.UserConfigPath())
2089 if cfg.ReasoningLanguage() != "auto" {
2090 t.Fatalf("reasoning language changed after rejected update: %q", cfg.ReasoningLanguage())
2091 }
2092 }
2093
2094 func TestSetDesktopCheckUpdatesPersistsToUserConfig(t *testing.T) {
2095 isolateDesktopUserDirs(t)
2096
2097 app := NewApp()
2098 if !app.Settings().CheckUpdates {
2099 t.Fatal("Settings().CheckUpdates default = false, want true")
2100 }
2101 if err := app.SetDesktopCheckUpdates(false); err != nil {
2102 t.Fatalf("SetDesktopCheckUpdates: %v", err)
2103 }
2104 view := app.Settings()
2105 if view.CheckUpdates {
2106 t.Fatal("Settings().CheckUpdates = true, want false")
2107 }
2108 cfg := config.LoadForEdit(config.UserConfigPath())
2109 if cfg.Desktop.CheckUpdates == nil || *cfg.Desktop.CheckUpdates {
2110 t.Fatalf("desktop.check_updates = %+v, want false", cfg.Desktop.CheckUpdates)
2111 }
2112 if cfg.DesktopCheckUpdates() {
2113 t.Fatal("DesktopCheckUpdates() = true, want false")
2114 }
2115 }
2116
2117 func TestSetDesktopUpdateChannelMigratesToStable(t *testing.T) {
2118 isolateDesktopUserDirs(t)
2119
2120 app := NewApp()
2121 if got := app.Settings().UpdateChannel; got != "stable" {
2122 t.Fatalf("Settings().UpdateChannel default = %q, want stable", got)
2123 }
2124 if err := app.SetDesktopUpdateChannel("canary"); err != nil {
2125 t.Fatalf("SetDesktopUpdateChannel: %v", err)
2126 }
2127 view := app.Settings()
2128 if view.UpdateChannel != "stable" {
2129 t.Fatalf("Settings().UpdateChannel = %q, want stable", view.UpdateChannel)
2130 }
2131 cfg := config.LoadForEdit(config.UserConfigPath())
2132 if cfg.Desktop.UpdateChannel != "" {
2133 t.Fatalf("desktop.update_channel = %q, want omitted legacy field", cfg.Desktop.UpdateChannel)
2134 }
2135 if cfg.DesktopUpdateChannel() != "stable" {
2136 t.Fatalf("DesktopUpdateChannel() = %q, want stable", cfg.DesktopUpdateChannel())
2137 }
2138 }
2139
2140 func TestSetDesktopConversationWidthPersistsToUserConfig(t *testing.T) {
2141 isolateDesktopUserDirs(t)
2142
2143 app := NewApp()
2144 if got := app.Settings().ConversationWidth; got != "standard" {
2145 t.Fatalf("Settings().ConversationWidth default = %q, want standard", got)
2146 }
2147 if got := app.DesktopStartupSettings().ConversationWidth; got != "standard" {
2148 t.Fatalf("DesktopStartupSettings().ConversationWidth default = %q, want standard", got)
2149 }
2150 if err := app.SetDesktopConversationWidth("full"); err != nil {
2151 t.Fatalf("SetDesktopConversationWidth: %v", err)
2152 }
2153 if got := app.Settings().ConversationWidth; got != "full" {
2154 t.Fatalf("Settings().ConversationWidth = %q, want full", got)
2155 }
2156 if got := app.DesktopStartupSettings().ConversationWidth; got != "full" {
2157 t.Fatalf("DesktopStartupSettings().ConversationWidth = %q, want full", got)
2158 }
2159 cfg := config.LoadForEdit(config.UserConfigPath())
2160 if got := cfg.DesktopConversationWidth(); got != "full" {
2161 t.Fatalf("persisted conversation width = %q, want full", got)
2162 }
2163
2164 if err := app.SetDesktopConversationWidth("wide"); err == nil {
2165 t.Fatal("SetDesktopConversationWidth(wide) unexpectedly succeeded")
2166 }
2167 if got := config.LoadForEdit(config.UserConfigPath()).DesktopConversationWidth(); got != "full" {
2168 t.Fatalf("invalid update changed persisted conversation width to %q", got)
2169 }
2170
2171 raw, err := json.Marshal(app.DesktopStartupSettings())
2172 if err != nil {
2173 t.Fatalf("marshal DesktopStartupSettings: %v", err)
2174 }
2175 if !strings.Contains(string(raw), `"conversationWidth":"full"`) {
2176 t.Fatalf("startup bridge payload omitted conversationWidth: %s", raw)
2177 }
2178 }
2179
2180 func TestSetDefaultToolApprovalModePersistsToUserConfig(t *testing.T) {
2181 isolateDesktopUserDirs(t)
2182
2183 app := NewApp()
2184 if app.Settings().DefaultToolApprovalMode != control.ToolApprovalAuto {
2185 t.Fatalf("Settings().DefaultToolApprovalMode = %q, want auto", app.Settings().DefaultToolApprovalMode)
2186 }
2187 if err := app.SetDefaultToolApprovalMode(control.ToolApprovalAuto); err != nil {
2188 t.Fatalf("SetDefaultToolApprovalMode: %v", err)
2189 }
2190 view := app.Settings()
2191 if view.DefaultToolApprovalMode != control.ToolApprovalAuto {
2192 t.Fatalf("Settings().DefaultToolApprovalMode = %q, want auto", view.DefaultToolApprovalMode)
2193 }
2194 cfg := config.LoadForEdit(config.UserConfigPath())
2195 if cfg.Desktop.DefaultToolApprovalMode != control.ToolApprovalAuto {
2196 t.Fatalf("desktop.default_tool_approval_mode = %q, want auto", cfg.Desktop.DefaultToolApprovalMode)
2197 }
2198 if cfg.DesktopDefaultToolApprovalMode() != control.ToolApprovalAuto {
2199 t.Fatalf("DesktopDefaultToolApprovalMode() = %q, want auto", cfg.DesktopDefaultToolApprovalMode())
2200 }
2201 }
2202
2203 func TestRetiredAutoRecoveryCheckpointSettingsAreNoOps(t *testing.T) {
2204 isolateDesktopUserDirs(t)
2205
2206 cfgPath := config.UserConfigPath()
2207 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
2208 t.Fatalf("mkdir config: %v", err)
2209 }
2210 if err := os.WriteFile(cfgPath, []byte("[agent]\nauto_recovery_checkpoint = \"off\"\n"), 0o644); err != nil {
2211 t.Fatalf("write config: %v", err)
2212 }
2213 app := NewApp()
2214 if err := app.SetDefaultAutoRecoveryCheckpoint(false); err != nil {
2215 t.Fatalf("legacy setter: %v", err)
2216 }
2217 if app.RecoveryCheckpointEnabled() || app.RecoveryCheckpointEnabledTab("legacy") {
2218 t.Fatal("retired config or legacy setter re-enabled Auto Guard")
2219 }
2220 }
2221
2222 func TestSetDesktopMetricsDefaultsOnAndPersistsOff(t *testing.T) {
2223 isolateDesktopUserDirs(t)
2224
2225 app := NewApp()
2226 if !app.Settings().Metrics {
2227 t.Fatal("Settings().Metrics default = false, want true")
2228 }
2229 if err := app.SetDesktopMetrics(false); err != nil {
2230 t.Fatalf("SetDesktopMetrics: %v", err)
2231 }
2232 view := app.Settings()
2233 if view.Metrics {
2234 t.Fatal("Settings().Metrics = true, want false")
2235 }
2236 cfg := config.LoadForEdit(config.UserConfigPath())
2237 if cfg.Desktop.Metrics == nil || *cfg.Desktop.Metrics {
2238 t.Fatalf("desktop.metrics = %+v, want false", cfg.Desktop.Metrics)
2239 }
2240 if cfg.DesktopMetrics() {
2241 t.Fatal("DesktopMetrics() = true, want false")
2242 }
2243 }
2244
2245 func TestSaveHooksSettingsPreservesUnknownSettingsKeys(t *testing.T) {
2246 isolateDesktopUserDirs(t)
2247 path := hook.GlobalSettingsPath("")
2248 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
2249 t.Fatal(err)
2250 }
2251 if err := os.WriteFile(path, []byte(`{"theme":"dark","hooks":{"Stop":[{"command":"old"}]}}`), 0o644); err != nil {
2252 t.Fatal(err)
2253 }
2254
2255 app := NewApp()
2256 if err := app.SaveHooksSettings("global", []HookConfigView{{
2257 Event: string(hook.PreToolUse),
2258 Match: "bash",
2259 Command: "echo guard",
2260 }}); err != nil {
2261 t.Fatalf("SaveHooksSettings: %v", err)
2262 }
2263
2264 var raw map[string]json.RawMessage
2265 body, err := os.ReadFile(path)
2266 if err != nil {
2267 t.Fatal(err)
2268 }
2269 if err := json.Unmarshal(body, &raw); err != nil {
2270 t.Fatal(err)
2271 }
2272 if string(raw["theme"]) != `"dark"` {
2273 t.Fatalf("theme key was not preserved: %s", raw["theme"])
2274 }
2275 view := app.HooksSettings("global")
2276 if len(view.Hooks) != 1 || view.Hooks[0].Event != string(hook.PreToolUse) || view.Hooks[0].Command != "echo guard" {
2277 t.Fatalf("HooksSettings = %+v, want saved PreToolUse hook", view)
2278 }
2279 }
2280
2281 func TestSaveHooksSettingsDecodesLegacyEncodedGlobalSettings(t *testing.T) {
2282 isolateDesktopUserDirs(t)
2283 path := hook.GlobalSettingsPath("")
2284 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
2285 t.Fatal(err)
2286 }
2287 legacy := `{"label":"中文","hooks":{"Stop":[{"command":"echo 旧"}]}}`
2288 if err := os.WriteFile(path, fileencoding.Encode(legacy, fileencoding.GB18030), 0o644); err != nil {
2289 t.Fatal(err)
2290 }
2291
2292 app := NewApp()
2293 before := app.HooksSettings("global")
2294 if len(before.Hooks) != 1 || before.Hooks[0].Command != "echo 旧" {
2295 t.Fatalf("HooksSettings before save = %+v, want decoded legacy hook", before.Hooks)
2296 }
2297 if err := app.SaveHooksSettings("global", []HookConfigView{{
2298 Event: string(hook.PreToolUse),
2299 Command: "echo 新",
2300 }}); err != nil {
2301 t.Fatalf("SaveHooksSettings: %v", err)
2302 }
2303
2304 body, err := os.ReadFile(path)
2305 if err != nil {
2306 t.Fatal(err)
2307 }
2308 var raw map[string]json.RawMessage
2309 if err := json.Unmarshal(body, &raw); err != nil {
2310 t.Fatalf("saved settings should be valid UTF-8 JSON: %v", err)
2311 }
2312 if string(raw["label"]) != `"中文"` {
2313 t.Fatalf("label key was not preserved after decoding legacy settings: %s", raw["label"])
2314 }
2315 view := app.HooksSettings("global")
2316 if len(view.Hooks) != 1 || view.Hooks[0].Command != "echo 新" {
2317 t.Fatalf("HooksSettings after save = %+v, want new decoded hook", view.Hooks)
2318 }
2319 }
2320
2321 func TestSaveHooksSettingsNormalizesQuotedNodeEvalHookCommand(t *testing.T) {
2322 isolateDesktopUserDirs(t)
2323 script := "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(payload.toolName)"
2324 bad := `node -e "\"` + script + `\""`
2325 want := hook.NormalizeCommand(bad)
2326 if want == bad {
2327 t.Fatal("test command did not normalize")
2328 }
2329
2330 app := NewApp()
2331 if err := app.SaveHooksSettings("global", []HookConfigView{{
2332 Event: string(hook.PreToolUse),
2333 Match: "bash",
2334 Command: bad,
2335 }}); err != nil {
2336 t.Fatalf("SaveHooksSettings: %v", err)
2337 }
2338
2339 view := app.HooksSettings("global")
2340 if len(view.Hooks) != 1 || view.Hooks[0].Command != want {
2341 t.Fatalf("HooksSettings = %+v, want normalized command %q", view.Hooks, want)
2342 }
2343 }
2344
2345 func TestProjectHooksSettingsUseActiveWorkspaceRootAndLoadByDefault(t *testing.T) {
2346 isolateDesktopUserDirs(t)
2347 project := t.TempDir()
2348 app := NewApp()
2349 app.tabs = map[string]*WorkspaceTab{
2350 "project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
2351 }
2352 app.activeTabID = "project"
2353
2354 if err := app.SaveHooksSettings("project", []HookConfigView{{
2355 Event: string(hook.Stop),
2356 Command: "echo done",
2357 Description: "Turn done",
2358 }}); err != nil {
2359 t.Fatalf("SaveHooksSettings(project): %v", err)
2360 }
2361 view := app.HooksSettings("project")
2362 if view.Scope != "project" || view.ProjectRoot != project || !view.Trusted {
2363 t.Fatalf("project hook view metadata = %+v", view)
2364 }
2365 if len(view.Hooks) != 1 || view.Hooks[0].Event != string(hook.Stop) || view.Hooks[0].Description != "Turn done" {
2366 t.Fatalf("project hooks = %+v", view.Hooks)
2367 }
2368 if _, err := os.Stat(filepath.Join(project, ".reasonix", "settings.json")); err != nil {
2369 t.Fatalf("project hooks settings file missing: %v", err)
2370 }
2371 loaded := hook.Load(hook.LoadOptions{ProjectRoot: project})
2372 if len(loaded) != 1 || loaded[0].Scope != hook.ScopeProject || loaded[0].Event != hook.Stop {
2373 t.Fatalf("project hooks should load by default: %+v", loaded)
2374 }
2375 }
2376
2377 func TestLegacyTrustProjectHooksMethodsAreNoOps(t *testing.T) {
2378 isolateDesktopUserDirs(t)
2379 app := NewApp()
2380 if err := app.TrustProjectHooks(); err != nil {
2381 t.Fatalf("TrustProjectHooks compatibility call: %v", err)
2382 }
2383 if err := app.TrustProjectHooksForRoot(t.TempDir()); err != nil {
2384 t.Fatalf("TrustProjectHooksForRoot compatibility call: %v", err)
2385 }
2386 }
2387
2388 func TestSaveHooksSettingsForRootUsesDisplayedProjectRoot(t *testing.T) {
2389 isolateDesktopUserDirs(t)
2390 projectA := t.TempDir()
2391 projectB := t.TempDir()
2392 app := NewApp()
2393 app.tabs = map[string]*WorkspaceTab{
2394 "a": {ID: "a", Scope: "project", WorkspaceRoot: projectA, Ready: true},
2395 "b": {ID: "b", Scope: "project", WorkspaceRoot: projectB, Ready: true},
2396 }
2397 app.activeTabID = "b"
2398
2399 if err := app.SaveHooksSettingsForRoot("project", projectA, []HookConfigView{{
2400 Event: string(hook.Stop),
2401 Command: "echo done",
2402 }}); err != nil {
2403 t.Fatalf("SaveHooksSettingsForRoot: %v", err)
2404 }
2405 if _, err := os.Stat(filepath.Join(projectA, ".reasonix", "settings.json")); err != nil {
2406 t.Fatalf("displayed project root settings missing: %v", err)
2407 }
2408 if _, err := os.Stat(filepath.Join(projectB, ".reasonix", "settings.json")); err == nil {
2409 t.Fatal("active project root was written instead of displayed project root")
2410 }
2411 }
2412
2413 // TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess locks the
2414 // read-path contract: loading a legacy-form config (configured providers but
2415 // no declared desktop.provider_access) through the View helpers returns a
2416 // normalized in-memory view while leaving the file bytes untouched. The
2417 // on-disk migration only happens once a locked write path runs.
2418 func TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess(t *testing.T) {
2419 isolateDesktopUserDirs(t)
2420 userPath := config.UserConfigPath()
2421 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
2422 t.Fatal(err)
2423 }
2424 legacy := "default_model = \"local/m1\"\n\n[[providers]]\nname = \"local\"\nbase_url = \"http://127.0.0.1:9999/v1\"\nmodels = [\"m1\"]\n"
2425 if err := os.WriteFile(userPath, []byte(legacy), 0o644); err != nil {
2426 t.Fatal(err)
2427 }
2428
2429 app := NewApp()
2430 for name, load := range map[string]func() (*config.Config, string, error){
2431 "view": app.loadDesktopUserConfigForView,
2432 "view-with-credentials": app.loadDesktopUserConfigForViewWithCredentials,
2433 } {
2434 cfg, _, err := load()
2435 if err != nil {
2436 t.Fatalf("%s load: %v", name, err)
2437 }
2438 if len(cfg.Desktop.ProviderAccess) == 0 {
2439 t.Fatalf("%s load should normalize legacy provider access in memory", name)
2440 }
2441 raw, err := os.ReadFile(userPath)
2442 if err != nil {
2443 t.Fatal(err)
2444 }
2445 if string(raw) != legacy {
2446 t.Fatalf("%s load must not rewrite the user config, got:\n%s", name, raw)
2447 }
2448 }
2449
2450 // The first locked write path persists the pending migration.
2451 if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
2452 t.Fatalf("applyConfigOnly: %v", err)
2453 }
2454 if !configDeclaresProviderAccess(userPath) {
2455 t.Fatal("locked write path should persist the provider access migration to disk")
2456 }
2457 migrated := config.LoadForEditWithoutCredentials(userPath)
2458 if len(migrated.Desktop.ProviderAccess) == 0 {
2459 t.Fatalf("migrated config lost provider access: %v", migrated.Desktop.ProviderAccess)
2460 }
2461 }
2462
2463 // TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory locks the
2464 // same contract for the legacy bot-config migration: read paths (including the
2465 // bot runtime's credential-loading view) see the merged bot config in memory
2466 // without any file being written; the locked write path performs the on-disk
2467 // migration.
2468 func TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory(t *testing.T) {
2469 isolateDesktopUserDirs(t)
2470 userPath := config.UserConfigPath()
2471 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
2472 t.Fatal(err)
2473 }
2474 userBody := "default_model = \"local/m1\"\n"
2475 if err := os.WriteFile(userPath, []byte(userBody), 0o644); err != nil {
2476 t.Fatal(err)
2477 }
2478 legacyRoot := t.TempDir()
2479 legacyPath := filepath.Join(legacyRoot, "reasonix.toml")
2480 legacyBody := "[bot]\nenabled = true\nmodel = \"local/m1\"\n"
2481 if err := os.WriteFile(legacyPath, []byte(legacyBody), 0o644); err != nil {
2482 t.Fatal(err)
2483 }
2484
2485 app := NewApp()
2486 app.tabs = map[string]*WorkspaceTab{
2487 "t": {ID: "t", Scope: "project", WorkspaceRoot: legacyRoot, Ready: true},
2488 }
2489 app.activeTabID = "t"
2490
2491 assertFilesUntouched := func(step string) {
2492 t.Helper()
2493 rawUser, err := os.ReadFile(userPath)
2494 if err != nil {
2495 t.Fatal(err)
2496 }
2497 if string(rawUser) != userBody {
2498 t.Fatalf("%s must not rewrite the user config, got:\n%s", step, rawUser)
2499 }
2500 rawLegacy, err := os.ReadFile(legacyPath)
2501 if err != nil {
2502 t.Fatal(err)
2503 }
2504 if string(rawLegacy) != legacyBody {
2505 t.Fatalf("%s must not rewrite the legacy config, got:\n%s", step, rawLegacy)
2506 }
2507 }
2508
2509 cfg, _, err := app.loadDesktopUserConfigForView()
2510 if err != nil {
2511 t.Fatalf("loadDesktopUserConfigForView: %v", err)
2512 }
2513 if !cfg.Bot.Enabled {
2514 t.Fatal("view load should merge the legacy bot config in memory")
2515 }
2516 assertFilesUntouched("loadDesktopUserConfigForView")
2517
2518 botCfg, err := app.loadDesktopBotConfig()
2519 if err != nil {
2520 t.Fatalf("loadDesktopBotConfig: %v", err)
2521 }
2522 if !botCfg.Bot.Enabled {
2523 t.Fatal("bot runtime load should see the merged legacy bot config")
2524 }
2525 assertFilesUntouched("loadDesktopBotConfig")
2526
2527 // The first locked write path migrates the bot config into the user file.
2528 if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
2529 t.Fatalf("applyConfigOnly: %v", err)
2530 }
2531 migrated := config.LoadForEditWithoutCredentials(userPath)
2532 if !migrated.Bot.Enabled {
2533 t.Fatal("locked write path should persist the legacy bot config migration")
2534 }
2535 rawLegacy, err := os.ReadFile(legacyPath)
2536 if err != nil {
2537 t.Fatal(err)
2538 }
2539 if string(rawLegacy) != legacyBody {
2540 t.Fatalf("migration must not rewrite the legacy config, got:\n%s", rawLegacy)
2541 }
2542 }
2543
2544 func TestLoadDesktopUserConfigForRootDoesNotFollowActiveTab(t *testing.T) {
2545 isolateDesktopUserDirs(t)
2546 userPath := config.UserConfigPath()
2547 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
2548 t.Fatal(err)
2549 }
2550 if err := os.WriteFile(userPath, []byte("default_model = \"local/m1\"\n"), 0o644); err != nil {
2551 t.Fatal(err)
2552 }
2553 targetRoot := t.TempDir()
2554 activeRoot := t.TempDir()
2555 if err := os.WriteFile(filepath.Join(targetRoot, "reasonix.toml"), []byte("[bot]\nenabled = true\nmodel = \"target\"\n"), 0o644); err != nil {
2556 t.Fatal(err)
2557 }
2558 if err := os.WriteFile(filepath.Join(activeRoot, "reasonix.toml"), []byte("[bot]\nenabled = true\nmodel = \"active\"\n"), 0o644); err != nil {
2559 t.Fatal(err)
2560 }
2561
2562 app := NewApp()
2563 app.tabs = map[string]*WorkspaceTab{
2564 "active": {ID: "active", Scope: "project", WorkspaceRoot: activeRoot, Ready: true},
2565 }
2566 app.activeTabID = "active"
2567
2568 cfg, _, err := app.loadDesktopUserConfigForViewForRoot(targetRoot)
2569 if err != nil {
2570 t.Fatalf("loadDesktopUserConfigForViewForRoot: %v", err)
2571 }
2572 if !cfg.Bot.Enabled || cfg.Bot.Model != "target" {
2573 t.Fatalf("root-specific view followed active tab: bot = %+v", cfg.Bot)
2574 }
2575
2576 unlock := config.LockUserConfigEdits()
2577 _, _, err = app.loadDesktopUserConfigForEditForRoot(targetRoot)
2578 unlock()
2579 if err != nil {
2580 t.Fatalf("loadDesktopUserConfigForEditForRoot: %v", err)
2581 }
2582 migrated := config.LoadForEditWithoutCredentials(userPath)
2583 if !migrated.Bot.Enabled || migrated.Bot.Model != "target" {
2584 t.Fatalf("root-specific edit migrated the active tab instead: bot = %+v", migrated.Bot)
2585 }
2586 }
2587
2588 func TestSetBotSettingsPreservesFeishuOutboundMediaRoots(t *testing.T) {
2589 isolateDesktopUserDirs(t)
2590 root := t.TempDir()
2591 cfg := config.Default()
2592 cfg.Bot.Feishu.OutboundMediaRoots = []string{root}
2593 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2594 t.Fatalf("save initial config: %v", err)
2595 }
2596
2597 app := NewApp()
2598 view := botSettingsView(cfg.Bot)
2599 view.QueueCap++
2600 if err := app.SetBotSettings(view); err != nil {
2601 t.Fatalf("SetBotSettings: %v", err)
2602 }
2603
2604 got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
2605 if !reflect.DeepEqual(got.Bot.Feishu.OutboundMediaRoots, []string{root}) {
2606 t.Fatalf("outbound media roots = %v, want preserved %q", got.Bot.Feishu.OutboundMediaRoots, root)
2607 }
2608 }
2609
2609 lines GO